mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75896541c | ||
|
|
36e1c04758 | ||
|
|
882b22aa62 | ||
|
|
782b6ab419 | ||
|
|
b22d7e9fc3 | ||
|
|
accae9cfbf | ||
|
|
70862a79d8 | ||
|
|
386c131a66 | ||
|
|
9b5e25cdf9 | ||
|
|
dec35d7529 | ||
|
|
45de17f617 | ||
|
|
7c130038f6 | ||
|
|
3ce1a9142f | ||
|
|
78010a93d8 | ||
|
|
d2531168fa | ||
|
|
fe7d0f6df5 | ||
|
|
fbd3f9cfa3 | ||
|
|
6a50b4a74b | ||
|
|
0359f24879 | ||
|
|
d83397ac20 | ||
|
|
6d2b4ca0a0 | ||
|
|
77a10fbdd3 | ||
|
|
9405d4798d | ||
|
|
550ca817e5 | ||
|
|
de65f8dbef | ||
|
|
3a76271878 | ||
|
|
ce3b460fcb | ||
|
|
09320554bb | ||
|
|
5a69f95cc7 | ||
|
|
96986f533e | ||
|
|
5a2bb2dacd | ||
|
|
0ec7afeafa | ||
|
|
e205d3bae6 | ||
|
|
efe5f9b3e9 | ||
|
|
dfce767894 | ||
|
|
17af2ebfd5 | ||
|
|
d31915e4ee | ||
|
|
d180622a53 | ||
|
|
df9969ee58 | ||
|
|
fe7edfea3f | ||
|
|
82e1e66047 | ||
|
|
fcaaa561ce | ||
|
|
831f3707d3 | ||
|
|
98bb7cfa6c | ||
|
|
d1d55ee391 | ||
|
|
541066da6d | ||
|
|
bdc2148619 | ||
|
|
0871352331 | ||
|
|
ca5beaf95e | ||
|
|
127976d8b7 | ||
|
|
435f8cc839 | ||
|
|
c88da85414 | ||
|
|
5d713d4838 | ||
|
|
45c12b26b3 | ||
|
|
651663ad1d | ||
|
|
26643ed11d | ||
|
|
1b95f3a14a | ||
|
|
e71ba84f1d | ||
|
|
e9384c0cfc | ||
|
|
0c0f23dee0 | ||
|
|
3a9dc0a5a5 | ||
|
|
b925856276 | ||
|
|
8769717242 | ||
|
|
32c3992f95 | ||
|
|
948836c99a | ||
|
|
ae096ba3a1 | ||
|
|
2664d953cd | ||
|
|
e32d3d9155 | ||
|
|
abe7f6e49d | ||
|
|
1df96e1de7 | ||
|
|
590ce561eb | ||
|
|
f937260387 | ||
|
|
fb169e9cdb | ||
|
|
88261cd5ae |
No files matched your search
Generated
+427
-10
@@ -99,6 +99,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -124,6 +130,12 @@ dependencies = [
|
||||
"virtue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
@@ -145,6 +157,24 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.5.1"
|
||||
@@ -163,6 +193,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
@@ -264,6 +300,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -297,6 +339,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -319,6 +367,36 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -332,7 +410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -347,22 +425,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.4.4-preview.1"
|
||||
version = "0.5.0-preview.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"clap",
|
||||
"flate2",
|
||||
"hex",
|
||||
"image",
|
||||
"libc",
|
||||
"pbkdf2",
|
||||
"pinyin",
|
||||
"rand",
|
||||
"portable-pty",
|
||||
"rand 0.8.8",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"tungstenite",
|
||||
"unicode-normalization",
|
||||
"ureq",
|
||||
"url",
|
||||
@@ -392,6 +475,18 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
@@ -400,7 +495,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -439,6 +534,22 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
@@ -549,6 +660,32 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -581,6 +718,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -611,6 +754,16 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -621,6 +774,37 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -661,6 +845,40 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -688,6 +906,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -697,6 +927,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
@@ -710,8 +946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -721,7 +967,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -733,6 +989,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
@@ -782,7 +1047,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -873,6 +1138,28 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -884,6 +1171,22 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -953,6 +1256,16 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -966,13 +1279,33 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1014,6 +1347,23 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.21",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -1071,6 +1421,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -1115,6 +1471,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -1133,6 +1498,28 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@@ -1221,6 +1608,21 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -1343,7 +1745,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror",
|
||||
"thiserror 2.0.21",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -1398,3 +1800,18 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.4.4-preview.1"
|
||||
version = "0.5.0-preview.2"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -24,11 +24,13 @@ pinyin = "0.11"
|
||||
vibrato = { version = "=0.5.0", default-features = false }
|
||||
zstd = "0.13"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
portable-pty = { version = "0.9", default-features = false }
|
||||
tungstenite = { version = "0.28", default-features = false, features = ["handshake"] }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -21,7 +21,7 @@ The target device is a Linux ARM64 Steam Frame. Enable developer mode and SSH on
|
||||
|
||||
### Desktop installer
|
||||
|
||||
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
|
||||
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
|
||||
|
||||
### Command installation
|
||||
|
||||
@@ -34,12 +34,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
For a Preview release, specify an actual published tag:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.4-preview.1
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
|
||||
```
|
||||
|
||||
Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation.
|
||||
|
||||
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Press the main trigger on the Framely Dock icon in the SteamVR Dashboard to open the launcher, or the upper trigger to open the quick menu. Open the manager from the launcher’s Settings button. The trigger actions can be swapped in Launcher settings.
|
||||
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Click the Framely Dock icon in the SteamVR Dashboard to open the launcher, or hold it for 600 ms to open the quick panel; a border progress indicator appears while holding. Open the manager from the launcher’s Settings button.
|
||||
|
||||
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
|
||||
|
||||
|
||||
+3
-3
@@ -21,7 +21,7 @@ Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR
|
||||
|
||||
### 安装器安装
|
||||
|
||||
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
|
||||
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.10) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
|
||||
|
||||
### 命令安装
|
||||
|
||||
@@ -34,12 +34,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
安装 Preview 时指定实际已发布的标签:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.4-preview.1
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.5-preview.2
|
||||
```
|
||||
|
||||
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
|
||||
|
||||
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中指向 Framely Dock 图标,按主扳机打开启动台,按上扳机打开快捷面板;点击启动台右侧的设置按钮打开管理面板。可在“启动台”设置中互换扳机行为。
|
||||
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中点击 Framely Dock 图标打开启动台,长按 600 毫秒打开快捷面板,按住期间显示边框进度;点击启动台右侧的设置按钮打开管理面板。
|
||||
|
||||
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
|
||||
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
|
||||
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
|
||||
5. [APK Manager](user-guide/apk-manager.md): sideload installation, updates, containers, retained data and cleanup.
|
||||
6. [Terminal and files](user-guide/terminal-and-files.md): user-owned terminal sessions, file operations, recycle bin, transfers and previews.
|
||||
|
||||
## Plugin development and publishing
|
||||
|
||||
|
||||
+25
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[简体中文](zh-CN/launcher.md)
|
||||
|
||||
Point at the Framely Dock icon: by default, use the primary trigger for the launcher and the upper trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** is enabled by default; turning it off swaps these shortcuts immediately. Existing saved trigger preferences are preserved. Other controllers keep the ordinary click entry.
|
||||
Click the Framely Dock icon with any supported click input to open the launcher. Hold it for **600 milliseconds** to open the quick panel; a border progress indicator shows the hold duration. Releasing after a long press does not also open the launcher. This replaces the primary/upper-trigger split and the trigger-swap setting.
|
||||
|
||||
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
|
||||
|
||||
@@ -31,3 +31,27 @@ Search accepts original names, English word initials, Chinese pinyin and Japanes
|
||||
Indexes are built asynchronously and cached on the device. Chinese uses offline pinyin data; Japanese uses the bundled Vibrato / IPADic reading dictionary. Han names receive both Chinese and Japanese candidates regardless of interface language. Unknown dictionary readings retain their original text. Original-name search remains available before phonetic indexing completes. No app names are sent to an external search service.
|
||||
|
||||
Session API: `launcher.search.index` accepts `{names: string[]}` and returns a name-keyed map of `{terms: string[]}`. `launcher.favorite` accepts `{key, favorite}` with the same stable keys as launcher ordering. Plugin and Steam keys update their existing favorites; `framely`, `desktop:` and local `lepton:` keys use `launcherFavorites`. `launcher.settings.save` now includes `allCategories` (`plugin`, `steam`, `lepton`, `desktop`); omitted fields migrate to all four categories, while an explicit empty array means none. Search dictionaries and notices are included in both core updates and offline packages under `share/search`.
|
||||
|
||||
## Gaze and controller confirmation
|
||||
|
||||
When the laser is not pointing at the launcher, valid eye tracking can select an icon, category or action button. A target becomes highlighted after 120 ms of stable gaze with a light controller pulse. Fixation jitter is lightly filtered, nearby icon gaps accept the nearest icon, and short tracking dropouts retain hover for up to 280 ms. A dropout during a press still cancels that press. Pointing the laser at the launcher restores laser interaction immediately; Steam window positioning controls and the VR keyboard pause gaze input. This uses launcher laser focus as the fallback rule, rather than assuming Steam’s private per-hand gamepad mode flags are available through OpenVR.
|
||||
|
||||
Use the left controller’s lower D-pad button (the position requested as “A”), right A, or either primary trigger to confirm. Short press opens the selected item; holding an icon for 400 ms opens its menu and 800 ms enters arrangement mode. The target is locked until release, so moving your eyes does not drag or switch the pressed icon. Arrange icons with the laser and joystick afterwards. Losing eye tracking or disconnecting the confirming controller cancels the press; buttons must be released before another press can begin. Unsupported or inactive tracking does not fall back to head direction.
|
||||
|
||||
### Gaze calibration
|
||||
|
||||
In the Frame management panel, open **Launcher → Eye tracking calibration → Start calibration**. You can also use the eye calibration button above Settings in the launcher’s right rail. A simulated launcher uses the same Dock surface, icon layout, search capsule and side rails. Start the guided sequence, then look naturally at the highlighted target; no confirmation press is needed. It samples 13 icons, six category controls, four shortcuts and search, then repeats five targets to validate the correction. Allow about one minute with uninterrupted tracking. Tracking loss pauses collection; excessive jitter asks you to retry. B or Cancel exits without saving.
|
||||
|
||||
You can turn or move your head to see peripheral targets. Each frame pairs the measured gaze with the direction from the current eye origin to the highlighted target, using the head pose at the same nominal sampling time. The target point is located using Steam's curved-overlay intersection, and is checked again as the head or window moves. Head movement itself is not treated as fixation jitter.
|
||||
|
||||
Paired directions are averaged in short time slices before fitting; each target has equal weight, and robust reweighting limits the influence of mis-fixated targets. Validation measures the median residual direction for each fixation, preserving per-frame head coordinates. A single spike cannot invalidate calibration.
|
||||
|
||||
The result shows RMS target bias and the worst target bias in degrees. Each target also shows its 95th percentile frame error and uncorrected median error, separating systematic bias from sensor noise. Calibration summaries and paired training blocks are recorded in the session log for diagnosis. **Save calibration** persists a correction of head-relative yaw and pitch. At runtime, the corrected direction is rotated into world space with the current headset pose, preserving the eye origin before intersecting the Dock surface. It does not change laser input or the system tracker. Validation compares against highlighted target directions, not against native gaze as a reference. Failed validation cannot overwrite the previous calibration. Old viewport-coordinate calibrations are ignored; run calibration again after this update. The correction handles systematic angular bias and gain; nonlinear tracking errors may remain. OpenVR does not expose the eye camera's exact exposure timestamp, so fast motion may still have timing error.
|
||||
|
||||
After substantial changes to headset fit or the window distance, run calibration again.
|
||||
|
||||
Press feedback appears only after holding for 200 ms. The Dock entry uses a blue border indicator and opens the quick panel at 600 ms. Launcher icons show a translucent white circular progress border: the halfway tick opens the menu at 400 ms; continuing to 800 ms enters icon editing. Release or cancellation removes the indicator.
|
||||
|
||||
### Remote Steam games
|
||||
|
||||
The Steam category also shows apps installed on a connected Remote Play computer. A small Remote Play badge appears at the bottom right of their icons. The list refreshes in the background; disconnected hosts disappear, and each App ID has one icon. If both local and remote installations are available, clicking it opens a choice between launching locally and Remote Play. Favorites and manual ordering remain shared for that game. Clicking a remote icon asks Steam to stream from its current host and checks availability again before launching. Artwork uses the local Steam cache, with a placeholder when unavailable. The integration uses the running Steam client UI; if that interface is unavailable, local games remain usable.
|
||||
+111
-15
@@ -1,5 +1,7 @@
|
||||
# APK Manager
|
||||
|
||||
Global controller and container lifecycle preferences are under **APK → Settings**, separate from the application list. Per-application display and launch preferences remain in each app’s management dialog.
|
||||
|
||||
[简体中文](../zh-CN/user-guide/apk-manager.md)
|
||||
|
||||
[Documentation](../README.md) · [Launcher](../launcher.md)
|
||||
@@ -18,13 +20,19 @@ Applications are identified by context plus package name. The same package in tw
|
||||
|
||||
## Install and update
|
||||
|
||||
Choose **Install APK**, select an individual `.apk`, and review its name, package, version, minimum Android SDK and target container. Uploads from the network panel are staged on Frame's persistent filesystem, not its limited `/tmp`. Transfer shows actual bytes; subsequent work shows named stages instead of invented percentages.
|
||||
During APK installation, choose **Display mode → Flat window / VR app**. The initial selection follows APK detection; updates keep your previous window override. Flat mode displays the Android window, while VR mode hides it and requires the app itself to support VR. A successful installation saves the choice; failed installations preserve the previous setting.
|
||||
|
||||
Choose **Install APK**, select an individual `.apk`, and review its name, package, version, minimum Android SDK and target container. Inside Frame, the selected APK is inspected directly on the device without a browser upload. A private local snapshot is retained for the confirmation step; the original file is not changed. Uploads from the network panel are staged on Frame's persistent filesystem, not its limited `/tmp`. Transfer shows actual bytes; subsequent work shows named stages instead of invented percentages.
|
||||
|
||||
Inside Frame, file inputs in both management panels use Framely’s built-in file picker, including APKs, plugin packages and language files. It opens Downloads by default, remembers the last selected folder, and provides a lazily loaded folder tree for Home, temporary files and mounted storage, with folder navigation, path entry, search, hidden files and file-type filtering. Select a file and confirm; B/Escape cancels the picker without closing the panel. External browsers continue using their system file picker. The launcher’s **Install APK** shortcut opens APK Manager.
|
||||
|
||||
New apps default to independent containers. Existing containers are offered only when they can accept the target package without overwriting another app's shared APK mount. Lepton recommends one application per container because Android apps do not have the usual enforced isolation inside its containers.
|
||||
Framely installs packages into separate Android package directories even when they share a container. Container restarts preserve downloaded resources in Android external storage, including unfinished download fragments and update checkpoints. Unrecognized Lepton media setup hooks are rejected before they can discard those files. Whether a game resumes its download still depends on its own updater. Older single-APK mounts are repaired from the saved APK when available, and legacy OBB resource links are migrated before replacement without deleting their original target. This prevents future loss; resources already deleted by an older startup cannot be reconstructed from the APK alone.
|
||||
|
||||
After installation, choose **Open** if desired; installation does not automatically launch the APK. An app with no detected launch entry remains manageable but does not appear in the launcher.
|
||||
Launcher icons use the highest-density packaged raster resource instead of the first resource-table entry. Existing installations are re-inspected when the panel/launcher refreshes; no APK reinstall is needed. PNG, WebP and JPEG icons retain their original bytes.
|
||||
|
||||
Each new app uses its own independent container. Existing shared containers remain discoverable; their existing packages can still be updated or managed, but new package identities cannot be added. Framely does not automatically split, migrate or delete older shared containers or their data. Steam-managed containers remain excluded.
|
||||
|
||||
Installation runs in a headless background container without opening the Android desktop, regardless of the selected display mode. After installation, choose **Open** if desired; installation does not automatically launch the APK. An app with no detected launch entry remains manageable but does not appear in the launcher. Opening an already visible app reuses its running container and virtual gamepad. A headless or fully hidden display, a physical window-size change, or first attaching a new gamepad requires a restart because Lepton cannot safely perform those display or device transitions in place.
|
||||
|
||||
To update, open the app's management dialog and choose **Install newer APK**. Package identity and version are checked; Android enforces signing compatibility during replacement. Downgrades are refused, and signature failures never trigger an uninstall/reinstall workaround. Same-package replacement retains data. Updating may stop the target container, affecting its other applications.
|
||||
|
||||
@@ -36,15 +44,17 @@ Once a modifying operation starts, cancellation is disabled until its actual out
|
||||
|
||||
The launcher uses declared MAIN/LAUNCHER, television launcher and recognized VR entries, plus the running Android package manager's resolved launcher entry. Disabled packages/components are excluded. A launch entry does not guarantee that an app will run successfully.
|
||||
|
||||
Flat apps use Lepton’s full-display boot path to avoid the Gamescope window-creation failure in per-app mode. A headless container from installation, or a closed display, is restarted when needed; this also stops other apps in the same container. VR apps keep background boot. After ActivityManager reports success, Framely checks that the application and Android display service stay alive before reporting completion. Framely keeps Lepton’s development-context data handling instead of switching an existing container to APK bake mode. A temporary launch adapter uses the installed Lepton libraries without editing Steam’s installation; an unsupported entry script reports an error before starting a cold container. Manually choosing **Start container** still opens its desktop.
|
||||
|
||||
Window handling is automatic by default. **Advanced settings → Show Android window** is a troubleshooting override, applied on container restart. It does not convert a flat application into VR or a VR application into a flat application. Lepton development contexts may mount their APK at a different path after a restart. Framely re-registers the same signed APK when Android can no longer resolve it, preserving data and reporting failures. Advanced settings also allow selecting an enabled, declared Activity when a nonstandard app needs a different entry.
|
||||
|
||||
Launcher favorites, search and manual ordering continue to use stable context/package identities. Updating and refreshing do not reorder icons. Uninstalling hides an icon without deleting its favorite/order record; reinstalling in the same context restores it. Long-press a local Lepton icon to open APK management or removal confirmation.
|
||||
|
||||
## Close, uninstall and clean data
|
||||
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app, even with Delete app data enabled, does not delete its container.
|
||||
**Close app** force-stops only the selected package. **Stop container** affects every app inside it. All non-Steam sideloaded containers with a verified data location support deletion after confirmation of affected apps and permanent data loss. Standard Lepton context directories are removed as a whole; registered external locations remove only the baked directory, preserving unrelated files in the parent. Uninstalling an app keeps its container unless **Delete container** is enabled.
|
||||
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data.
|
||||
Uninstallation retains saved data by default. Enable **Delete app data** to remove it. If the container contains only this app, this also selects **Delete container** by default; you can turn it off to keep the container. Shared containers and unknown application states cannot use this option. The backend checks the inventory again before deleting. Container deletion also removes the associated APK cache and Steam library entry, icon and artwork, while keeping historical backups. Retained apps appear under **Uninstalled, data retained**, where you can reinstall or remove retained data with the same container option.
|
||||
|
||||
Android may require the original signed APK to remove an already uninstalled package's retained state. Framely keeps the original APK when it manages an uninstall. If an externally uninstalled app has no recoverable APK, supply its original APK through reinstallation before removing its retained state. Framely does not bypass signing checks or delete arbitrary directories to simulate success.
|
||||
|
||||
@@ -57,24 +67,110 @@ Use the app's **Logs → Export logs** to save its operation log. Detailed Andro
|
||||
As the Steam session user, the installed binary also provides these commands:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
/var/lib/framely/current/bin/framely apk list
|
||||
/var/lib/framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/var/lib/framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/var/lib/framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/var/lib/framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/var/lib/framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
Reviews expire after 15 minutes. APKs, operation records and recovery material are stored in `~/.local/share/framely/apk-manager`; managed Lepton data lives under `~/.local/share/lepton/contexts`. Framely uninstallation retains these user-owned directories. Back them up before removing Framely; the APK CLI requires a compatible Framely binary, while Lepton remains independently installed through Steam.
|
||||
|
||||
For a Framely-managed context, a manual Lepton launch must preserve data:
|
||||
Start Framely-managed contexts through the APK panel or the `framely apk launch` command above. These launch paths preserve downloaded resources and attach persistent storage; a raw Lepton launch does not apply Framely's storage hooks.
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
### Downloaded resources and shader cache
|
||||
|
||||
On the next container start after upgrading, Framely moves existing Android shared storage to `baked/external` inside the context and mounts it directly, outside the Android data overlay. The move preserves partial downloads and checkpoints without copying their contents. If both storage locations already contain data or an unexpected link exists, startup stops and retains both locations for recovery. Already-running containers use the new layout after stopping and launching again. Registered Steam compatibility contexts retain their existing external-storage layout.
|
||||
|
||||
Each context also stores Mesa's shader cache in `baked/shadercache`, mounted at `/data/shaders`. Cache entries survive container restarts, allowing compatible shaders to be reused; first-time compilation still occurs. Managed context backups include both directories and restore downloaded resources and cache entries together with application data.
|
||||
|
||||
Do not invoke Lepton's global cleanup commands as an application-specific uninstall.
|
||||
|
||||
## Supported formats
|
||||
|
||||
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
|
||||
### Flat window orientation
|
||||
|
||||
In **Manage app → Advanced settings → Window orientation**, choose **Automatic**, **Landscape**, or **Portrait**. The choice is saved per app and applied at its next launch. Forced portrait uses a tall window rather than rotating content sideways. Changing orientation may restart the shared container and stop other apps in it. VR apps ignore this setting. Flat APK startup also explicitly selects fullscreen and applies it to the created Android task, avoiding a restored freeform desktop window. This affects startup only; later manual resizing remains available.
|
||||
|
||||
Moonlight V+ 12.12.12’s connection-creation screen currently crashes on the tested Lepton build because Android’s clipboard service is absent. This is distinct from the window-startup failure. Framely does not modify or re-sign the APK; a compatible Lepton runtime or an app-side fallback is required.
|
||||
|
||||
### Optional gamepad input
|
||||
|
||||
**APK → Settings → Gamepad input** is off by default, including after upgrading an existing installation. Enable it to forward Steam Input as a standard Android gamepad for APKs opened through Framely. Steam Input is the default source and follows Steam’s controller configurations, including external controllers recognized by Steam. **Frame direct** retains the original OpenVR bindings for compatibility. Close and restart a container to change its source. It includes ABXY, the D-pad and diagonals, both sticks and stick clicks, shoulder buttons, analog triggers, Select and Start. Applications must support Android gamepad input; this does not map controls to touchscreen gestures or provide Android VR controller tracking; supported games can request gamepad rumble.
|
||||
|
||||
Enabling applies on the next application launch. Framely may restart its container to attach the input device, stopping other apps in that container. Disabling stops forwarding immediately. Only one Framely-launched APK receives input at a time. Switching apps, closing an app, transient process-query failures and foreground changes neutralize input without removing the virtual pad. Each running container retains its device for reuse; container stop, disabling the feature or session-service restart releases it. After a service restart, launch the APK again to reconnect. Keep the intended Android window selected in Dock; the bridge does not change Steam's physical-controller bindings.
|
||||
|
||||
The bridge runs as the Steam session user and requires access to `/dev/uinput`, active SteamVR and the supported Lepton startup hooks. It does not modify Steam's installed Lepton files. Only its virtual event node and read-only Android key layout are mounted into the target container, where Android claims it exclusively. If this claim fails, no input is forwarded and the launch reports an error. Disable the option to use the normal launch path. Bridge errors are recorded in `~/.local/share/framely/apk-manager/logs/gamepad.log`; key presses are not logged. The native helper and Android adapter ship in both offline and core update packages.
|
||||
|
||||
Native Steam launches select the input target before startup acknowledgement. Forwarding begins only after Android claims the virtual device and the target application becomes foreground. Frame direct helpers have separate temporary SteamVR identities. Steam Input helpers do not register OpenVR action sets, because that registration can suppress Steam virtual-gamepad enumeration in SDL. Returning to an already running application reselects its input target.
|
||||
|
||||
When this option is enabled, launch APKs through the management panel or launcher. A one-shot CLI launch cannot own the persistent input bridge and reports an error instead. For read-only diagnostics, run `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'` as the Steam session user. It reports Android process, foreground and lifecycle status without input events or Steam tokens; it does not prove that the application handles gamepad events.
|
||||
|
||||
**Gamepad rumble** can be disabled independently and takes effect immediately. Linux `FF_RUMBLE` effects are acknowledged and sent back to the current source controller. Input source changes, Android foreground loss, disconnects and bridge shutdown stop feedback; stale effects are not resumed. Rumble intensity is capped at half strength and each request at ten seconds. Games must use Android gamepad vibration; phone vibration and Android VR haptics are not forwarded.
|
||||
|
||||
Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s virtual gamepads, never the physical devices directly. Framely’s output has a distinct device identity and is excluded to prevent a feedback loop. If the Steam runtime is unavailable, launch reports an error; choose Frame direct explicitly instead of silently bypassing Steam’s mappings. With multiple source controllers, the most recently active controller supplies input and receives rumble; they are not merged.
|
||||
|
||||
### 可选手柄输入
|
||||
|
||||
**APK → 设置 → 手柄输入**默认关闭,旧配置升级也保持关闭。开启后默认使用 Steam Input,沿用 Steam 的手柄配置,支持 Frame 手柄及 Steam 已识别的外接手柄。可选择 **Frame 直连(兼容模式)**保留原有 OpenVR 输入。切换来源需关闭容器后重新启动;关闭输入立即生效。
|
||||
|
||||
支持 ABXY、十字键及斜向、双摇杆及按压、肩键、模拟扳机、Select 与 Start;应用必须原生支持 Android 游戏手柄,不模拟触摸或 VR 控制器追踪。同时仅一个目标 APK 接收输入,Android 前台状态丢失、查询失败时清零,但保留虚拟设备以便恢复。
|
||||
|
||||
**手柄震动**可独立关闭并立即生效。支持游戏请求的 `FF_RUMBLE`,回传给当前输入手柄;切换来源、失去 Android 前台状态、断连或退出时停止,不恢复旧效果。强度上限为一半,单次请求最长十秒;不转发手机振动或 Android VR 触觉。多个手柄以最近产生有效输入的手柄为来源,不合并输入。
|
||||
|
||||
桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。Frame 直连使用独立的 SteamVR 身份;Steam Input 不注册 OpenVR 输入动作,避免影响 SDL 的虚拟手柄枚举。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。
|
||||
|
||||
## Container lifecycle
|
||||
|
||||
The APK page provides two persistent switches. **Stop container after app exits** defaults off: after a confirmed launch, all target app processes must remain absent for 15 seconds before the container stops. Background apps and services keep it alive. **Stop container when closing its window** defaults on: a confirmed system window-close signal stops the associated container (normally within a few seconds). Switching windows, minimizing, and opening the launcher do not count as closing.
|
||||
|
||||
Monitoring pauses during APK mutations and startup checks. A container restart, uncertain process query, or another active app in a legacy shared container prevents stopping. Steam-managed containers are left to Steam. Shutdown uses normal Podman stop with a 10-second grace period; APKs, saves and downloads are retained. Reopening a stopped container takes a cold Android start; with both switches disabled a healthy running container is reused.
|
||||
|
||||
### SteamOS window-close limitation
|
||||
|
||||
On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls log `Closing Wayland windows not supported yet.` for direct, non-Steam Lepton windows. The close request does not reach Android, so this switch cannot make these controls work on that path. Use **Launch through Steam** as described below, or the APK panel's **Stop container** action. Framely does not infer close intent from focus changes or this unscoped log message.
|
||||
|
||||
## Native Steam / Lepton launches
|
||||
|
||||
**Launch through Steam** defaults to on. Framely registers launchable sideloaded APKs with Steam's native Lepton compatibility tool. Existing opt-outs remain disabled, and Steam-managed titles are not registered again. Registration does not launch or restart applications. Steam must be awake; unavailable registration is retried in the background.
|
||||
|
||||
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
|
||||
|
||||
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
|
||||
|
||||
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
|
||||
|
||||
On the tested Frame build, native Lepton ownership lets both the window's lower close button and the Dock close button show Steam's exit confirmation. Confirming exit stops the app and its container. Steam **Stop** and APK management **Stop container** remain available. This result was verified with an isolated flat APK; application-specific and VR behavior may differ. Direct, non-Steam Lepton windows still encounter that build's unsupported Wayland close path.
|
||||
|
||||
Entry metadata and the launch adapter are configured through Steam's existing local library UI API, reached through its local debugging endpoint on port 8080. Framely neither enables the endpoint nor changes Steam launch settings to enable it. If the API is unavailable or the shortcut target cannot be verified, registration reports an error and retries; it does not edit the live shortcuts database or silently switch to an unprotected launch. This Steam UI interface can change with Steam updates.
|
||||
|
||||
Disabling Steam launch removes only Framely's entry; close a native Steam app first. Uninstalling an APK stops its native container before removing its entry. APK data is removed only by the explicitly selected uninstall/data-cleanup operation. The same session service verifies process and container boot identities so an old ownership record cannot stop a newer application.
|
||||
|
||||
### Steam / Lepton 原生启动
|
||||
|
||||
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
|
||||
|
||||
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
|
||||
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。平面 APK 启动后会通过 Android 任务接口切换为全屏,避免恢复成桌面内的自由窗口;不会持续锁定用户之后的窗口调整。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
|
||||
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
|
||||
|
||||
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
Container list headings show installed application names; the data path identifies the container. An APK update stops its container before backing up data. If stopping fails or a native Steam launch still owns it, the installation dialog offers **Close container** with confirmation. Closing interrupts running apps and retains their saves. The reviewed APK remains available so installation can be retried after closing.
|
||||
|
||||
### Steam entry retention
|
||||
|
||||
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
|
||||
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
|
||||
@@ -11,9 +11,9 @@ Click Framely in the SteamVR Dashboard Dock. First use requires accepting the te
|
||||
- **Favorites**: open favorite plugin quick pages.
|
||||
- **Installed**: browse enabled local plugins and their pages.
|
||||
- **Notifications**: view saved notifications and update reminders, and toggle floating notifications.
|
||||
- **Settings**: choose an installed language, toggle safe mode, open the plugin manager or **Check for updates**. The update entry opens the manager’s About/update page. Install language files and download the language template from the manager’s Settings page.
|
||||
- **Settings**: choose an installed language, toggle safe mode, open the plugin manager or **Check for updates**. The update entry opens the manager’s Settings → Updates page. Install language files and download the language template from the manager’s Settings page.
|
||||
|
||||
The manager opens a separate large window with navigation for plugins, plugin library, sources, settings, notification settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
|
||||
The manager opens a separate large window with five sections: Plugin, APK, Terminal, Files and Settings. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
|
||||
|
||||
## Plugins and windows
|
||||
|
||||
@@ -25,11 +25,11 @@ Dock windows use SteamVR close controls. Temporary windows may be destroyed when
|
||||
|
||||
Settings provides language, network panel and proxy configuration. HTTP and GitHub proxies have independent switches; saving an address does not automatically enable it.
|
||||
|
||||
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. About shows the device version, updates and terms. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
|
||||
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. Settings → About shows the device version and terms; Settings → Updates manages updates. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
|
||||
|
||||
In About, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
|
||||
In Settings → Updates, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
|
||||
|
||||
Automatic update checks are enabled by default. Framely checks about 30 seconds after starting (once terms are accepted), then every 6 hours. In About, turn **Check for updates automatically** off or change the interval. Checks use the selected Stable/Testing channel and configured proxy, continue with the panel closed, and never download or install automatically. A newer version saves a notification to the inbox and shows a popup when notification settings allow it. **Ignore** dismisses that version's reminder; **Open updates** opens About's update page. The same version is not repeatedly announced, including after restart. Switching the source or channel clears the old reminder.
|
||||
Automatic update checks are enabled by default. Framely checks about 30 seconds after starting (once terms are accepted), then every 6 hours. In Settings → Updates, turn **Check for updates automatically** off or change the interval. Checks use the selected Stable/Testing channel and configured proxy, continue with the panel closed, and never download or install automatically. A newer version saves a notification to the inbox and shows a popup when notification settings allow it. **Ignore** dismisses that version's reminder; **Open updates** opens Settings → Updates. The same version is not repeatedly announced, including after restart. Switching the source or channel clears the old reminder.
|
||||
|
||||
Floating notifications follow the headset near the upper-right of the view. Their input mask matches the visible card; short button clicks tolerate small tracking movements.
|
||||
|
||||
@@ -65,3 +65,9 @@ Hold the primary trigger and move the laser up or down to drag scrollable page c
|
||||
|
||||
|
||||
Launcher blank-space clicks only dismiss the icon menu. Click outside the launcher window or press B to dismiss it (B exits arrangement first). Steam’s move/depth controls keep the window open and pause stick navigation until the stick returns to neutral. While dragging an icon in arrangement mode, horizontal stick input changes pages; vertical input does not change categories. The boundary rails remain an alternative: hold the pointer beyond a rail for 650 ms to move to the adjacent page.
|
||||
|
||||
## Launcher resource priority and closing
|
||||
|
||||
Settings → Launcher includes **Prioritize launcher resources**, enabled by default. While the launcher is open, Framely raises its native UI and CEF thread CPU priority to a bounded nice level of −5, then restores the previous priorities when closed or disabled. Games and terminal processes are excluded. This can help CPU contention; it does not reserve GPU capacity. The installed session service must include the matching priority limit.
|
||||
|
||||
Closing the launcher returns to the previously selected Dock window when it is still available. Starting an application hands focus to Steam instead of restoring the old window. APK windows are handed off after Android confirms the activity launch; startup health checks continue afterward. A running container with a hidden application window is reused rather than restarted solely because the previous game exited.
|
||||
@@ -35,3 +35,5 @@ Change the port or switch in Settings → Network panel. Password/authentication
|
||||
Restarting the session service also invalidates old logins. Saving identical settings retains the session. Incorrect password checks are rate-limited; retry later when asked.
|
||||
|
||||
If unreachable, check changing device IPs, network reachability, enabled state and port. Settings and passwords live in `/home/.framely/state` and are retained by uninstall; reinstalling may still require the old password.
|
||||
|
||||
The top switcher provides Plugin, APK, Terminal, Files and Settings; the device address defaults to Plugin. [Terminal and files](terminal-and-files.md) provides user-owned shell sessions, uploads/downloads, editing, archives and previews. These tools use the same login and port.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Terminal and files
|
||||
|
||||
[简体中文](../zh-CN/user-guide/terminal-and-files.md)
|
||||
|
||||
[Documentation](../README.md) · [Network panel](network-panel.md)
|
||||
|
||||
## Manager navigation
|
||||
|
||||
The top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
|
||||
|
||||
Plugin contains installed plugins, the plugin library, sources and notification settings, including Framely's notifications. Plugin-specific settings remain in each plugin's management dialog. APK keeps its apps, containers and retained-data cleanup. Settings contains General, Launcher, Updates and About.
|
||||
|
||||
## Interaction feedback
|
||||
|
||||
In **Settings → General → Interaction feedback**, Haptic feedback and Sound feedback are enabled by default and saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed navigation and activation sounds for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
|
||||
|
||||
## Terminal
|
||||
|
||||
Create a terminal to open the configured Steam session user's login shell in their home directory, normally `steamos`. This is a real PTY: Ctrl-C, Tab completion, arrows, Unicode input, window resizing and full-screen programs such as Vim and top work. Use `sudo` inside the terminal when administrator privileges are needed; the panel password is separate from the user's sudo password.
|
||||
|
||||
You can create up to 12 sessions, rename them, reconnect or explicitly close them. Changing pages, closing a browser or losing the network leaves the shell running. Restarting the user-session service or Framely ends the sessions; processes are not restored after restart. Closing a session requires confirmation and releases the shell, its session's child processes and PTY.
|
||||
|
||||
Only one connection can type in a session. Other connections are read-only; **Take control** transfers input ownership. While disconnected, input is disabled. Reconnection never replays queued keystrokes and restores only the retained output. Each session has a 2 MiB output buffer; a notice appears when older output was discarded. Replayed raw terminal output can lose earlier screen context after truncation.
|
||||
|
||||
Terminal input, sudo passwords and output are not written to Framely logs. Output is rendered as terminal characters, not HTML; terminal clipboard-write escape sequences are ignored. xterm.js and its fit addon are included with the offline UI, without CDN requests.
|
||||
|
||||
Use **Ctrl+Shift+C** to copy selected terminal text and **Ctrl+Shift+V** to paste. **Ctrl+C** still interrupts the running program; it is not the copy shortcut. The footer shows these shortcuts.
|
||||
|
||||
## Browse and organize files
|
||||
|
||||
Files opens the user's home directory and accesses the filesystem with that user's permissions. It never automatically elevates privileges. Permission changes are limited to ordinary Unix permission bits; use Terminal and `sudo` for administrator operations.
|
||||
|
||||
Use the address bar, breadcrumbs, parent button or expandable folder tree. Quick access includes Home, Downloads, mounted storage, the filesystem root, the recycle bin and your saved folders. Add the current folder using the star button; rename, reorder or remove its shortcut with the adjacent controls. Unavailable folders keep their saved shortcut and display the access error when opened.
|
||||
|
||||
Drag the divider between the sidebar and file list to resize it; the width is remembered in this browser and limited automatically on narrow windows. Quick access stays in a fixed-height upper section. The folder tree fills the remaining sidebar height; both lists scroll independently, with their headings kept visible. Opening a folder in the file list or address bar expands the tree to its location and highlights it.
|
||||
|
||||
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are never scanned to create thumbnails.
|
||||
|
||||
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
|
||||
|
||||
Copy, move, upload and extraction let you skip conflicts, keep both names or confirm replacement for the batch. Replacement of a same-name folder replaces that whole folder; it does not merge its contents. Long tasks show actual bytes or completed entries, can be cancelled, and report individual failures. Completed items remain completed when a later item fails or the task is cancelled. Switching manager sections keeps active file work and editor state in the current page.
|
||||
|
||||
## Recycle bin
|
||||
|
||||
Delete defaults to **Move to recycle bin (30 days)**. Permanent deletion requires an explicit choice and confirmation. Framely keeps trash on the source filesystem: home-directory trash or a private `.framely-trash-UID` directory at the storage mount. If that storage cannot support trash, the operation reports an error and does not fall back to permanent deletion.
|
||||
|
||||
The recycle bin supports restoring with an unused name or keeping both copies, permanent removal and clearing all tracked items. Restore needs the original parent directory to exist. Framely cleans expired records about hourly while the session service runs, and only removes items recorded in its own recycle bin. Unavailable storage is retained until accessible again. Other applications' trash is unaffected.
|
||||
|
||||
## Transfer and edit
|
||||
|
||||
Upload multiple files or a folder from your browser. Transfers use 1 MiB chunks, byte progress and private temporary files; only a completed upload receives its final filename. Cancelled uploads are removed and hourly maintenance removes transfers idle for at least an hour. A stopped service may leave clearly named `.framely-upload-*` temporary files, never a falsely complete final file. Uploads are limited to 64 GiB per file and 64 simultaneous transfer records.
|
||||
|
||||
Download a single file directly, or select multiple files/folders to download a ZIP. Download links expire after an hour. Large files stream without loading the entire file into memory; single-byte ranges support video seeking and resuming downloads.
|
||||
|
||||
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
|
||||
|
||||
## Archives and media
|
||||
|
||||
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
|
||||
|
||||
Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, WebM and MOV use the browser's native decoder; actual codec support depends on the browser. Video controls provide playback, seeking and fullscreen. Unsupported formats can be downloaded; there is no server-side transcoding. HTML and SVG are not opened as same-origin pages.
|
||||
|
||||
## Access and privacy
|
||||
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings.
|
||||
|
||||
## Diagnostic export
|
||||
|
||||
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
|
||||
@@ -13,6 +13,7 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
|
||||
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
|
||||
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
|
||||
5. [APK 管理](user-guide/apk-manager.md):侧载安装、更新、容器、保留数据与清理。
|
||||
6. [终端与文件](user-guide/terminal-and-files.md):用户终端会话、文件操作、回收站、传输与预览。
|
||||
|
||||
## 插件开发与发布
|
||||
|
||||
|
||||
+25
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](../launcher.md)
|
||||
|
||||
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
|
||||
使用任意支持的点击输入点击 Framely Dock 图标,打开启动台。长按 **600 毫秒** 打开快捷面板,按住期间显示边框进度;长按完成后松手不会再打开启动台。此操作替代原来的主扳机、上扳机分工及扳机互换设置。
|
||||
|
||||
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
|
||||
|
||||
@@ -33,3 +33,27 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
|
||||
会话接口:`launcher.search.index` 接收 `{names: string[]}`,返回名称对应的 `{terms: string[]}`。`launcher.favorite` 接收 `{key, favorite}`,使用与图标排列相同的稳定标识。插件和 Steam 仍更新原有收藏,`framely`、`desktop:` 和本地 `lepton:` 标识保存于 `launcherFavorites`。`launcher.settings.save` 新增 `allCategories`(`plugin`、`steam`、`lepton`、`desktop`);旧配置省略字段时默认全选,显式空数组表示全部不选。词典和许可说明位于发布包 `share/search`,同时包含于本体更新包和完整离线包。
|
||||
|
||||
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
|
||||
|
||||
## 眼追与手柄确认
|
||||
|
||||
激光未指向启动台时,有效眼追可以选中图标、分类和操作按钮。稳定注视 120 毫秒后高亮目标,并轻振手柄;注视坐标经过轻度滤波,图标边缘附近也可命中最近图标;小幅抖动不会立即切换焦点,短暂丢帧最多保留高亮 280 毫秒。按住期间追踪失效仍会取消按下。激光指向启动台后立即恢复激光交互,Steam 的窗口移动控件及 VR 键盘会暂停眼追。这里使用“激光未指向启动台”的切换规则,不假定公开 OpenVR 能读取 Steam 私有的左右手柄模式标志。
|
||||
|
||||
使用左手柄下方方向键(需求中称为“A”的位置)、右手柄 A 或任意手柄的主扳机确认。短按打开选中项,图标长按 400 毫秒打开菜单,800 毫秒进入排列模式。按下后锁定目标,眼睛移动不会拖动图标或切换正在按住的目标;随后使用激光和摇杆排列。眼追失效或确认手柄断连会取消当前按下,必须松开按键才接受下一次操作。追踪不可用时不会用头部朝向替代眼追。
|
||||
|
||||
### 眼追校准
|
||||
|
||||
在 Frame 内的管理面板打开“启动台 → 眼追校准 → 开始校准”。也可点击启动台右侧设置按钮上方的眼追校准入口。模拟界面沿用同一个 Dock 窗口、图标排列、搜索框和两侧胶囊。开始后自然注视依次高亮的目标,不需要按键确认。采集 13 个图标、6 个分类按钮、4 个快捷按钮和搜索框,再重复采集 5 个位置验证修正效果。连续追踪时约需一分钟;追踪失效会暂停采集,抖动过大会提示重试。B 或取消退出,不保存结果。
|
||||
|
||||
可以转头或移动头部来看清边缘目标。每帧都结合当时的头显姿态,把测得的注视方向与“当前眼睛起点到高亮目标”的方向配对。目标位置通过 Steam 实际曲面命中反求,并在头部或窗口移动后重新检查;头部移动本身不算注视抖动。
|
||||
|
||||
拟合前对短时间片内的成对方向一起平均,每个目标权重相同,并通过稳健重加权减小采错目标的影响。验证使用每次注视的方向残差中位数,始终保留逐帧头部坐标;单个尖峰帧不会否决整次校准。
|
||||
|
||||
结果显示各目标偏差的均方根和最差目标偏差,单位为度。各目标还显示 95% 帧误差以及未修正时的中位误差,区分持续偏差和追踪噪声。校准摘要与训练时间片数据会写入会话日志,便于定位问题。“保存校准”持久保存头显坐标系内偏航、俯仰角的修正。运行时使用当前头显姿态把修正方向转换到空间坐标,保留眼睛起点,再与 Dock 曲面求交。不会改变激光输入或系统眼追。误差参照高亮目标方向,不以原生眼追作为正确答案。验证失败不会覆盖原有校准;旧版窗口坐标校准不再应用,更新后请重新校准。此修正处理稳定角度偏移与增益,非线性追踪误差仍可能存在。OpenVR 未公开眼睛摄像头的准确曝光时间,快速移动时仍可能有时序误差。
|
||||
|
||||
明显调整头显佩戴方式或窗口距离后,请重新校准。
|
||||
|
||||
按住 200 毫秒后才显示进度反馈。Dock 入口使用蓝色边框进度,600 毫秒打开快捷面板。启动台图标使用半透明白色圆形边框进度:中点刻度对应 400 毫秒打开菜单,继续按至 800 毫秒进入图标编辑;松手或取消按压后移除进度。
|
||||
|
||||
### Steam 远程畅玩
|
||||
|
||||
Steam 分类也显示已连接远程畅玩电脑上安装的应用,图标右下角标有远程图标。列表后台刷新,断连的电脑不再展示;相同 App ID 只显示一个图标;本机和远程都可用时,点击弹窗选择本机启动或远程畅玩,收藏和手动排序保持同一份。点击远程图标会先重新检查可用状态,再让 Steam 从对应电脑串流。图片使用本机 Steam 缓存,缺图显示占位图。此功能读取正在运行的 Steam 客户端界面;该接口不可用时,本机游戏仍可使用。
|
||||
@@ -1,5 +1,7 @@
|
||||
# APK 管理
|
||||
|
||||
全局手柄与容器生命周期选项位于 **APK → 设置**,不再占用应用列表。每个应用的显示与启动选项仍保留在对应应用的管理弹窗内。
|
||||
|
||||
[English](../../user-guide/apk-manager.md)
|
||||
|
||||
[文档首页](../README.md) · [启动台](../launcher.md)
|
||||
@@ -20,13 +22,21 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
|
||||
|
||||
## 安装与更新
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
安装确认中可选择 **显示方式 → 平面窗口 / VR 应用**。初始选项依据 APK 检测结果,更新时保留已有窗口设置。平面模式显示安卓窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。安装成功后保存选择,安装失败保留原设置。
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。在 Frame 内选择本机 APK 会直接在设备上校验,不经过浏览器上传;确认阶段保留独立本机副本,不修改原文件。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
|
||||
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
|
||||
|
||||
新应用默认独立容器。现有容器只有在不会覆盖其他应用的共享 APK 挂载层时才可作为目标。Lepton 建议每个应用独立容器,因为容器内没有通常 Android 应用之间的强制隔离。
|
||||
共享容器中的 APK 使用各自独立的 Android 安装目录。容器重启会保留 Android 外部存储中的已下载资源,包括未完成的下载片段和更新进度记录。无法识别的 Lepton 存储初始化脚本会在执行前拒绝,避免丢弃文件;游戏是否继续下载仍由自身更新器决定。旧版单 APK 挂载会在保存的安装包可用时恢复,旧 OBB 资源链接会在替换安装前迁移,并保留原目标。这只能防止后续丢失,旧版启动流程已经删除的下载资源无法仅靠 APK 还原。
|
||||
|
||||
安装不会自动打开应用,完成后可以点击“打开”。没有检测到启动入口的 APK 仍可管理,但不会放进启动台。
|
||||
已显示的应用再次打开时复用运行中的容器和虚拟手柄。无显示或已完全隐藏的窗口、改变窗口物理尺寸、首次挂载新的手柄设备时仍需重启,因为 Lepton 无法安全地在运行中完成这些显示或设备切换。
|
||||
|
||||
启动台图标优先使用 APK 中最高密度的位图资源,不再直接取资源表第一项。已安装应用会在面板或启动台刷新时重新提取,无须重装 APK;PNG、WebP、JPEG 图标保留原始字节。
|
||||
|
||||
每个新应用固定使用独立容器。已有共享容器继续识别,允许更新和管理其中已安装的应用,但不能再添加新的包名。Framely 不会自动拆分、迁移或删除旧共享容器及其数据;Steam 管理的容器仍不作为安装目标。
|
||||
|
||||
安装全程使用无显示的后台容器,不打开安卓桌面,不受所选显示方式影响。安装不会自动打开应用,完成后可以点击“打开”。没有检测到启动入口的 APK 仍可管理,但不会放进启动台。
|
||||
|
||||
更新时进入应用管理,点击“安装新版 APK”。先核对包名与版本,再由 Android 在覆盖安装时执行签名兼容校验。默认拒绝降级;签名失败不会自动卸载重装。同包覆盖安装保留数据。更新可能停止目标容器,影响其中其他应用。
|
||||
|
||||
@@ -38,15 +48,17 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
|
||||
|
||||
后台识别清单中的 MAIN/LAUNCHER、电视启动入口和已知 VR 入口,并结合运行中的 Android 包管理器解析结果。停用的包与组件排除。有入口不代表一定能正常运行。
|
||||
|
||||
平面应用使用 Lepton 完整显示窗口的启动路径,避免按应用创建窗口时触发 Gamescope 协议错误。安装留下的无显示容器或已关闭的显示窗口会按需重启;重启也会停止同容器内其他应用。VR 应用继续后台启动。ActivityManager 报告启动成功后,Framely 还会确认应用和安卓显示服务持续运行,再报告操作完成。Framely 保留 Lepton 开发容器的数据处理方式,不把已有容器切换到 APK 烘焙模式。临时启动适配脚本使用设备上已安装的 Lepton 库,不修改 Steam 安装目录;遇到不支持的入口脚本,会在冷启动容器前报错。手动“启动容器”仍保留显示桌面的行为。
|
||||
|
||||
默认自动处理窗口显示。“高级设置 → 显示安卓窗口”用于排查,并在重启容器后生效。它不会把二维应用转成 VR,也不会把 VR 应用转成二维。Lepton 开发容器重启后可能改变 APK 挂载路径;Android 无法识别时,Framely 会重新登记同签名 APK,保留数据并报告错误。非标准应用还可从已启用、已声明的 Activity 中指定启动入口。
|
||||
|
||||
启动台继续按稳定的容器与包名保存收藏、搜索和排序。更新或刷新不重排图标;卸载隐藏图标,保留收藏与位置记录,在原容器重新安装后恢复。长按本地 Lepton 图标可进入管理或卸载确认。
|
||||
|
||||
## 关闭、卸载与清理
|
||||
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用、包括选择删除应用数据,都不会删除容器。
|
||||
“关闭应用”只停止目标包;“停止容器”影响其中全部应用。所有可确认数据位置的非 Steam 侧载容器均支持删除,并需确认受影响应用及永久数据删除。标准 Lepton 目录中的容器会整体删除;手动登记的外部位置只删除 baked 目录,保留父目录中的其他文件。卸载应用时默认保留容器,开启“删除容器”才同时删除。
|
||||
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据。
|
||||
卸载默认保留存档与设置,开启“删除应用数据”才彻底删除。如果容器内只有该应用,会默认勾选“删除容器”,也可手动关闭以保留容器。共享容器或应用状态未知时无法使用该选项,后端删除前会再次核对应用列表。删除容器会同时清理关联 APK 缓存和 Steam 库条目、图标及封面,历史备份保留。保留数据的应用显示在“已卸载但保留数据”中,可以重新安装或清除保留数据,清除时同样提供删除容器选项。
|
||||
|
||||
Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely 管理卸载时会保存原 APK。如果其他工具已卸载应用且找不到原 APK,需要先提供原 APK 重新安装,再清除保留数据;不会绕过签名或删除任意目录假装成功。
|
||||
|
||||
@@ -61,24 +73,78 @@ Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely
|
||||
以 Steam 会话用户执行:
|
||||
|
||||
```bash
|
||||
/home/.framely/current/bin/framely apk list
|
||||
/home/.framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/home/.framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/home/.framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/home/.framely/current/bin/framely apk cleanup-list
|
||||
/var/lib/framely/current/bin/framely apk list
|
||||
/var/lib/framely/current/bin/framely apk inspect --file /path/to/app.apk
|
||||
/var/lib/framely/current/bin/framely apk install '{"ticket":"REVIEW_TICKET","approve":true}'
|
||||
/var/lib/framely/current/bin/framely apk launch '{"app":"CONTEXT/PACKAGE"}'
|
||||
/var/lib/framely/current/bin/framely apk logs '{"app":"CONTEXT/PACKAGE"}'
|
||||
/var/lib/framely/current/bin/framely apk cleanup-list
|
||||
```
|
||||
|
||||
校验票据有效期为 15 分钟。APK、管理记录和恢复材料位于 `~/.local/share/framely/apk-manager`,托管 Lepton 数据位于 `~/.local/share/lepton/contexts`。卸载 Framely 保留这些用户目录;卸载前应备份。APK 命令仍需兼容的 Framely 二进制,Lepton 本身由 Steam 独立安装。
|
||||
|
||||
手动启动 Framely 托管容器时,必须保留数据:
|
||||
请通过 APK 面板或上面的 `framely apk launch` 命令启动 Framely 托管容器。这些入口会保留下载资源并挂载持久存储;直接调用 Lepton 不会应用 Framely 的存储适配。
|
||||
|
||||
```bash
|
||||
LEPTON_NO_CLEANUP=true ~/.local/share/Steam/steamapps/common/Lepton/lepton start CONTEXT
|
||||
```
|
||||
### 下载资源与 shader 缓存
|
||||
|
||||
升级后的下一次容器启动会把已有 Android 共享存储移动到容器内的 `baked/external`,直接挂载,绕开 Android 数据的 OverlayFS 层。迁移只移动目录,保留未完成下载和断点记录,不复制资源内容。若两处目录均已有数据或发现未知链接,会停止启动并保留数据供恢复。已经运行的容器需停止后再次启动才应用新布局;登记的 Steam 兼容数据容器保留原有独立资源布局。
|
||||
|
||||
每个容器的 Mesa shader 缓存保存在 `baked/shadercache`,挂载到 `/data/shaders`,重启后仍保留,可复用兼容的已编译 shader;首次使用仍需编译。托管容器备份包含这两个目录,恢复时会随应用数据一起恢复下载资源和缓存。
|
||||
|
||||
不要将 Lepton 的全局清理命令用作单应用卸载。
|
||||
|
||||
## 格式范围
|
||||
|
||||
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
|
||||
### 平面窗口方向
|
||||
|
||||
在 **管理应用 → 高级设置 → 窗口方向** 中选择 **自动选择、横屏或竖屏**。每个应用独立保存,下次启动时应用。竖屏使用纵向窗口,而不是让内容侧着显示。切换方向可能重启共享容器,停止其中其他应用;VR 应用忽略此设置。
|
||||
|
||||
Moonlight V+ 12.12.12 的创建连接界面在当前测试的 Lepton 版本上会因缺少 Android 剪贴板服务而崩溃,与此前窗口启动失败是两个问题。Framely 不修改或重新签名 APK,需要兼容的 Lepton 运行环境或应用端容错处理。
|
||||
|
||||
### 可选的 Frame 手柄输入
|
||||
|
||||
**APK → 应用 → Frame 手柄输入**默认关闭,旧配置升级后也保持关闭。开启后,通过 Framely 打开的 APK 可以将两个 Frame 手柄作为一个标准 Android 游戏手柄使用,包括 ABXY、十字键及斜向组合、双摇杆及按下、肩键、模拟扳机、Select 和 Start。应用本身需要支持 Android 游戏手柄;该功能不把按键映射为触屏操作,也不提供 Android VR 手柄追踪或振动。
|
||||
|
||||
开启后在下次打开应用时生效。接入输入设备可能需要重启容器,影响其中其他正在运行的应用。关闭开关立即停止转发。同一时间只向一个由 Framely 打开的 APK 转发。切换或关闭应用、进程查询暂时失败、前台切换时只暂停输入,不移除虚拟手柄。每个运行中的容器保留自己的输入设备供下次打开复用;停止容器、关闭开关或会话服务重启时释放设备。服务重启后再次打开 APK 即可重新接入。使用时在 Dock 中选中目标 Android 窗口;该功能不修改 Steam 的实体手柄绑定。
|
||||
|
||||
桥接以 Steam 会话用户运行,需要 `/dev/uinput` 访问权限、已运行的 SteamVR 和受支持的 Lepton 启动接口,不修改 Steam 安装目录中的 Lepton 文件。只向目标容器挂载自己的虚拟输入节点,由 Android 独占接收。接管失败时不转发输入,并提示启动错误;可以关闭开关恢复普通启动。错误记录在 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键内容。原生桥接和 Android 适配库均包含在离线包及核心更新包中。
|
||||
|
||||
原生 Steam 启动在等待启动确认之前选择输入目标。只有 Android 接管虚拟设备、目标应用进入前台后,才开始转发。每个容器的桥接进程使用独立的临时 SteamVR 身份,避免其他桥接进程覆盖其登记。返回已运行应用时重新选择输入目标。
|
||||
|
||||
开启此选项后,请通过管理面板或启动台打开 APK。一次性命令行进程无法管理持续运行的输入桥接,此时命令行启动会明确报错。只读诊断可由 Steam 会话用户运行 `framely apk input-status '{"app":"CONTEXT/PACKAGE"}'`,查看 Android 进程、前台及生命周期状态,不输出输入事件或 Steam 令牌;这些状态不能证明应用实际处理了手柄事件。
|
||||
|
||||
|
||||
|
||||
## 容器退出策略
|
||||
|
||||
APK 页面提供两个持久保存的开关。**应用退出后自动停止容器**默认关闭:确认启动后,应用的所有相关进程连续消失 15 秒才停止容器;后台应用或服务仍存在时不会停止。**关闭窗口时停止容器**默认开启:确认系统发出的窗口关闭信号后停止对应容器,通常需要数秒。切换窗口、最小化和打开启动台不属于关闭。
|
||||
|
||||
APK 安装、更新、其他修改与启动检查期间暂停监测。容器重启、进程状态无法确认或旧共享容器内还有其他应用运行时,不停止容器;Steam 管理的容器交给 Steam。停止使用正常 Podman 停止流程,给予 10 秒退出时间,保留 APK、存档和下载数据。已停止的容器再次打开需要冷启动 Android;关闭两个开关后仍会复用健康的运行中容器。
|
||||
|
||||
### Steam / Lepton 原生启动
|
||||
|
||||
**通过 Steam 启动**默认开启。Framely 使用 Steam 原生 Lepton 兼容工具注册可启动的侧载 APK,保留主动关闭的选择,跳过 Steam 已管理的应用。注册不会启动应用或重启容器;Steam 需要保持唤醒,不可用时会后台重试。
|
||||
|
||||
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
|
||||
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
|
||||
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
|
||||
|
||||
独立平面 APK 的实机验证显示,窗口下方和 Dock 的关闭按钮都能弹出 Steam 退出确认,确认后应用和容器停止。也可使用 Steam 的“停止”或 APK 管理中的“停止容器”。具体应用及 VR 应用行为可能不同;直接启动的非 Steam Lepton 窗口仍受当前系统未实现 Wayland 关闭的限制。
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
|
||||
|
||||
### Steam 入口保留
|
||||
|
||||
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
|
||||
|
||||
### 卸载并保留容器
|
||||
|
||||
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
|
||||
@@ -11,9 +11,9 @@
|
||||
- **常用**:打开收藏的插件快捷页。
|
||||
- **已安装**:查看已启用的本机插件,进入插件页面。
|
||||
- **通知**:查看收件箱消息和更新提醒,并控制悬浮通知。
|
||||
- **设置**:选择已安装的语言、切换安全模式,或打开“管理插件”和“检查更新”。“检查更新”打开管理窗口的关于/更新页面;安装语言文件和下载语言模板在管理窗口的设置中进行。
|
||||
- **设置**:选择已安装的语言、切换安全模式,或打开“管理插件”和“检查更新”。“检查更新”打开管理窗口的设置 → 更新页面;安装语言文件和下载语言模板在管理窗口的设置中进行。
|
||||
|
||||
“管理插件”打开独立大窗口,左侧导航进入插件、插件库、插件源、设置、通知设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
|
||||
“管理插件”打开独立大窗口,顶部进入插件、APK、终端、文件与设置五个区域。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
|
||||
|
||||
## 插件启停与窗口
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
|
||||
设置中可修改语言、网络面板及代理。HTTP 代理和 GitHub 代理各有开关,保存地址不会自动启用代理。
|
||||
|
||||
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“关于”查看本体版本、检查更新和复查协议。撤回协议会停用全部插件,再次同意后需要手动启用。
|
||||
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“设置 → 关于”查看本体版本和协议,在“设置 → 更新”检查更新。撤回协议会停用全部插件,再次同意后需要手动启用。
|
||||
|
||||
在“关于”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
|
||||
在“设置 → 更新”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
|
||||
|
||||
日志排查命令见[安装指南](installation.md#修复与日志)。界面能打开不代表所有插件都正常;先逐个启用,确认运行身份与来源可信。
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
管理窗口的“通知设置”提供全局“悬浮通知”和“启动图标角标”开关,并为 Framely 本体(包括更新提醒)及所有已安装插件(包括已停用插件)提供独立的“角标”和“悬浮”开关。全局开关优先,关闭后仍保留各应用偏好。Framely Dock 启动图标的角标统计允许显示角标来源的未读收件箱消息,超过 99 显示 `99+`;实际打开通知标签后消除角标,隐藏的头显面板不会自动标记已读。查看消息不会删除消息,也不会关闭其操作按钮。设置和已读状态均会保存并在重启后恢复。
|
||||
|
||||
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“关于”的更新页关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
|
||||
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“设置 → 更新”关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
|
||||
|
||||
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
|
||||
|
||||
@@ -69,3 +69,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
|
||||
|
||||
|
||||
启动台窗口内部的空白点击只收起图标菜单。点击窗口外或按 B 关闭;调整模式下 B 先退出调整。Steam 的移动、推远和拉近控件会保持窗口打开,并暂停摇杆导航,摇杆回中后恢复。调整模式拖拽图标时可以用摇杆左右翻页,上下不会切换分类;也可将指针移过分页线并停留 650 毫秒跨页。
|
||||
|
||||
## 启动台资源优先与关闭
|
||||
|
||||
设置 → 启动台新增默认开启的**启动台资源优先**。启动台打开时,将 Framely 原生界面与 CEF 线程的 CPU 调度优先级提高到有界的 nice −5,关闭启动台或禁用设置后恢复原优先级。游戏与终端进程不参与提升。此设置可以改善 CPU 竞争,不预留 GPU 资源;安装后的会话服务需要包含对应的优先级限制。
|
||||
|
||||
关闭启动台时,之前选中的 Dock 窗口仍存在则返回该窗口。启动应用时由 Steam 接管焦点,不恢复旧窗口。Android 确认应用启动后即交接 APK 窗口,后续仍进行启动存活检查。运行中的容器即使已隐藏应用窗口也会复用,不再仅因之前游戏退出就重启。
|
||||
@@ -35,3 +35,5 @@ VR 窗口、手柄振动和 SteamVR 虚拟键盘需要头显端验收;浏览
|
||||
会话服务重启也会使旧登录失效,需要重新登录。保存完全相同的设置不会撤销当前会话。错误密码验证有速率限制,提示尝试过多时稍后再试。
|
||||
|
||||
无法访问时检查设备 IP 是否变化、两台设备是否处于可互通的网络、面板是否开启以及端口是否正确。设置/密码保存在 `/home/.framely/state`,卸载本体保留这些状态,重装后可能仍需原密码。
|
||||
|
||||
顶部切换器提供插件、APK、终端、文件与设置;设备地址默认打开插件。[终端与文件](terminal-and-files.md)提供用户 Shell 会话、上传下载、编辑、压缩及预览,复用相同登录和端口。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 终端与文件
|
||||
|
||||
[English](../../user-guide/terminal-and-files.md)
|
||||
|
||||
[文档索引](../README.md) · [网络面板](network-panel.md)
|
||||
|
||||
## 管理面板导航
|
||||
|
||||
顶部切换器为 **插件 / APK / 终端 / 文件 / 设置**。直接访问设备 IP 默认进入插件;各区域在当前浏览器中记住上次子页面。原有 `#settings`、`#launcher-settings`、`#about`、`#updates` 链接继续有效。
|
||||
|
||||
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
|
||||
|
||||
## 交互反馈
|
||||
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈和提示音反馈默认开启,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机的导航和点击音效,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
|
||||
## 终端
|
||||
|
||||
新建终端会以安装时配置的 Steam 会话用户打开登录 Shell,初始目录为该用户主目录,通常是 `steamos`。真实 PTY 支持 Ctrl-C、Tab 补全、方向键、中文、尺寸调整,以及 Vim、top 等全屏程序。需要管理员权限时在终端使用 `sudo`;面板访问密码与 sudo 密码不同。
|
||||
|
||||
最多创建 12 个会话,支持改名、重连及明确关闭。切换页面、关闭浏览器或断网不结束 Shell;用户会话服务或 Framely 重启后结束,不恢复进程。关闭须确认,并回收 Shell、属于该 Unix 会话的子进程及 PTY。
|
||||
|
||||
同一会话只允许一个连接输入,其他连接只读;“接管控制”转移输入权限。断网时禁止输入,重连不重放按键,仅恢复保留输出。每个会话缓存上限 2 MiB,截断旧输出时显示提示;重放截断后的原始终端数据可能缺少此前屏幕上下文。
|
||||
|
||||
终端输入、sudo 密码及输出不写入 Framely 日志。输出作为终端字符渲染,不执行 HTML,忽略写剪贴板的转义请求。xterm.js 和尺寸适配依赖随离线包提供,不使用 CDN。
|
||||
|
||||
使用 **Ctrl+Shift+C** 复制终端选中文本,**Ctrl+Shift+V** 粘贴。**Ctrl+C** 用于中断运行中的程序,不作为复制快捷键;终端底部会显示提示。
|
||||
|
||||
## 浏览与整理文件
|
||||
|
||||
默认打开用户主目录,以该用户权限访问文件系统,不自动提权。权限编辑仅支持普通 Unix 权限位;管理员操作请使用终端里的 `sudo`。
|
||||
|
||||
通过地址栏、面包屑、上级按钮或可展开文件夹树导航。快速访问包含主目录、下载、挂载存储、文件系统根目录、回收站及自定义文件夹。星标按钮添加当前目录,旁边按钮可改名、排序、移除;暂不可用的目录保留记录,打开时显示访问错误。
|
||||
|
||||
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不扫描媒体内容生成缩略图。
|
||||
|
||||
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
|
||||
|
||||
复制、移动、上传、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
|
||||
|
||||
拖动侧栏与文件列表之间的分隔线可调整宽度,并在当前浏览器中记住;窄窗口会自动限制宽度。快速访问固定在顶部一定高度,目录树占据侧栏剩余高度;两个列表分别在内部滚动,标题保持可见,从右侧列表或地址栏进入文件夹时,目录树会展开并标记对应位置。
|
||||
|
||||
右键文件或文件夹会在指针处打开操作菜单;右键已选中的项目时保留多选。右键列表空白处可新建文件或文件夹、粘贴、上传和刷新。按 Escape 或点击其他位置关闭菜单。
|
||||
|
||||
## 回收站
|
||||
|
||||
删除默认选择“移至回收站(30 天)”,永久删除须主动选择并确认。回收站保存在原文件系统内:主目录回收站,或存储挂载点的私有 `.framely-trash-UID` 目录。存储无法支持回收站时报告错误,不自动改成永久删除。
|
||||
|
||||
支持恢复到空闲名称或保留两份、永久删除及清空已登记项目。恢复要求原父目录存在。用户会话服务运行时约每小时检查到期项目,只清理 Framely 登记的回收站文件;存储离线时保留记录,恢复访问后再处理。不影响其他应用的回收站。
|
||||
|
||||
## 传输与编辑
|
||||
|
||||
网页可上传多个文件或文件夹,使用 1 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
|
||||
|
||||
单文件可直接下载,多选文件或文件夹以 ZIP 下载;下载链接一小时后失效。大文件流式传输,单段 Range 请求支持视频拖动和下载续传。
|
||||
|
||||
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
|
||||
|
||||
## 压缩与媒体
|
||||
|
||||
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
|
||||
|
||||
选择图片或视频后才读取内容预览。PNG、JPEG、GIF、WebP、AVIF、BMP、MP4、WebM、MOV 使用浏览器原生解码,具体编码支持取决于浏览器;视频提供播放、暂停、拖动及全屏。不支持的格式提示下载,不转码。HTML、SVG 不作为同源页面打开。
|
||||
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
|
||||
|
||||
## 导出诊断日志
|
||||
|
||||
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
|
||||
Generated
+1
-1
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.9"
|
||||
version = "0.4.1-preview.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.9"
|
||||
version = "0.4.1-preview.10"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+7
-1
@@ -32,6 +32,8 @@ After an operation, maintenance shows a separate success/failure card and a reco
|
||||
|
||||
Scanning, downloads, and SSH work run on background threads, with progress and logs in the UI. The network panel button assumes port 15915; use the actual address if the device port has changed.
|
||||
|
||||
After a successful first installation, the installer automatically opens the network management panel in your browser. The highlighted **Management panel** button remains available after installation and updates.
|
||||
|
||||
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
|
||||
|
||||
Release queries, checksum files and package downloads automatically use the computer's manual system proxy: Windows Internet Settings, macOS network settings, or Linux GNOME/KDE settings. `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (or their lowercase forms) override desktop settings; `NO_PROXY` and desktop bypass rules are respected, including on redirects. HTTP CONNECT and SOCKS proxies are supported. PAC scripts and IPv6 proxy endpoints are currently unsupported; use a manual proxy with a hostname or IPv4 address. SSH/SFTP and downloads initiated by Frame use the device connection and network rather than the computer's HTTP proxy.
|
||||
@@ -62,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
|
||||
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.9`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
|
||||
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
|
||||
|
||||
@@ -83,3 +85,7 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
|
||||
After connecting to Frame, click **Export logs** in the footer and choose a ZIP destination on your computer. You do not need to select a release or installation package. Collection runs directly over SSH with administrator access, independently of Framely’s web panel and daemon. It also works with stopped services or damaged state, and includes the installer’s current operation log. Export does not install, restart or change Framely.
|
||||
|
||||
The archive includes bounded recent service/plugin/CEF logs, GPU evidence, installation versions and a plugin status summary. Missing sources are listed in `report.json`. Plugin settings, inbox contents and password files are excluded; common credential patterns are masked. Review logs before sharing. Cancelling the save dialog leaves the installation untouched.
|
||||
|
||||
### Proxy preferences
|
||||
|
||||
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
|
||||
@@ -32,6 +32,8 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
|
||||
扫描、下载和 SSH 工作在后台线程运行,界面展示进度和日志。默认网络管理面板按钮使用端口 15915;设备修改端口后应使用实际地址。
|
||||
|
||||
首次安装成功后,安装器会自动在浏览器打开网络管理面板;高亮的“管理面板”按钮在安装和更新完成后仍可使用。
|
||||
|
||||
在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。
|
||||
|
||||
版本查询、校验文件和压缩包下载自动读取电脑的手动系统代理:Windows Internet 设置、macOS 网络设置及 Linux GNOME/KDE 设置。`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`(也支持小写)优先于桌面设置;遵循 `NO_PROXY` 及桌面绕过规则,重定向后也会重新判断。支持 HTTP CONNECT 和 SOCKS 代理。暂不支持 PAC 脚本及 IPv6 代理地址,可使用主机名或 IPv4 地址的手动代理。SSH/SFTP 使用设备连接,Frame 主动下载使用设备网络,不使用电脑的 HTTP 代理。
|
||||
@@ -62,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
|
||||
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.9`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
|
||||
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
|
||||
|
||||
@@ -83,3 +85,7 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
|
||||
连接 Frame 后,点击底部的 **导出日志**,选择电脑上的 ZIP 保存位置,无需选择发行版本或安装包。安装器通过 SSH 和管理员权限独立收集,不依赖网络面板或本体服务;服务停止、状态文件损坏时仍能导出,并包含安装器当前的操作日志。导出不会安装、重启或修改 Framely。
|
||||
|
||||
日志包包含有大小限制的近期服务、插件和 CEF 日志、GPU 信息、安装版本及插件状态摘要。缺失来源记录在 `report.json` 中。不包含插件设置、收件箱内容和密码文件,并过滤常见凭据格式;分享前仍应检查日志。取消保存弹窗不会改变安装状态。
|
||||
|
||||
### 代理设置
|
||||
|
||||
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
|
||||
+100
-4
@@ -48,6 +48,9 @@ enum Event {
|
||||
}
|
||||
struct Installer {
|
||||
page: usize,
|
||||
system_proxy: bool,
|
||||
http_proxy: Entity<InputState>,
|
||||
github_proxy: Entity<InputState>,
|
||||
chosen_action: String,
|
||||
device_state: Option<Installation>,
|
||||
preview: bool,
|
||||
@@ -104,9 +107,18 @@ impl Installer {
|
||||
.masked(masked)
|
||||
})
|
||||
};
|
||||
let proxy_result = framely_installer::proxy::load();
|
||||
let proxy_error = proxy_result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|_| "无法读取代理设置,请在设置页重新保存。".to_owned());
|
||||
let proxies = proxy_result.unwrap_or_default();
|
||||
let networks = discovery::networks();
|
||||
let mut view = Self {
|
||||
page: 0,
|
||||
system_proxy: proxies.system,
|
||||
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
|
||||
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
|
||||
chosen_action: "install".into(),
|
||||
device_state: None,
|
||||
preview: cfg!(feature = "visual-test")
|
||||
@@ -145,7 +157,7 @@ impl Installer {
|
||||
progress: None,
|
||||
logs: Vec::new(),
|
||||
status: "开启 Frame 开发者模式并设置密码,将电脑和 Frame 连接到同一网络。".into(),
|
||||
error: None,
|
||||
error: proxy_error,
|
||||
release_error: None,
|
||||
connection_stage: None,
|
||||
sender,
|
||||
@@ -343,6 +355,17 @@ impl Installer {
|
||||
} else {
|
||||
"操作失败,请查看操作结果与日志。".into()
|
||||
};
|
||||
let first_install = result.is_ok()
|
||||
&& self.chosen_action == "install"
|
||||
&& self
|
||||
.device_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| !state.present);
|
||||
if first_install && !self.preview {
|
||||
if let Some(url) = self.manager_url() {
|
||||
cx.open_url(&url);
|
||||
}
|
||||
}
|
||||
self.queue_prompt(dialogs::Prompt::Outcome(
|
||||
self.chosen_action.clone(),
|
||||
result.clone(),
|
||||
@@ -362,6 +385,27 @@ impl Installer {
|
||||
}
|
||||
self.present_prompt(window, cx);
|
||||
}
|
||||
fn manager_url(&self) -> Option<String> {
|
||||
let completed = self
|
||||
.operation_result
|
||||
.as_ref()
|
||||
.is_some_and(|(action, result)| result.is_ok() && action != "uninstall");
|
||||
if self.connected_host.is_empty() || (self.connection.is_none() && !completed) {
|
||||
return None;
|
||||
}
|
||||
let host = self
|
||||
.connected_host
|
||||
.rsplit_once(':')
|
||||
.map(|(host, _)| host)
|
||||
.unwrap_or(&self.connected_host)
|
||||
.trim_matches(['[', ']']);
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_owned()
|
||||
};
|
||||
Some(format!("http://{host}:15915/manager"))
|
||||
}
|
||||
fn export_logs(&mut self, cx: &mut Context<Self>) {
|
||||
if self.busy {
|
||||
return;
|
||||
@@ -555,6 +599,30 @@ impl Installer {
|
||||
self.queue_prompt(dialogs::Prompt::Operation(self.chosen_action.clone()));
|
||||
cx.notify();
|
||||
}
|
||||
fn save_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = framely_installer::proxy::DownloadSettings {
|
||||
system: self.system_proxy,
|
||||
http: self.http_proxy.read(cx).value().trim().to_owned(),
|
||||
github: self
|
||||
.github_proxy
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
};
|
||||
match framely_installer::proxy::save(settings) {
|
||||
Ok(()) => {
|
||||
self.error = None;
|
||||
self.release_error = None;
|
||||
self.releases.clear();
|
||||
self.selected = None;
|
||||
self.status = "代理设置已保存,下次下载及检查版本时生效。".into();
|
||||
}
|
||||
Err(error) => self.error = Some(format!("保存代理设置失败:{error}")),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
fn navigate(&mut self, page: usize, cx: &mut Context<Self>) {
|
||||
if self.busy {
|
||||
return;
|
||||
@@ -769,6 +837,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
(0, "devices"),
|
||||
(1, "versions"),
|
||||
(2, "maintenance"),
|
||||
(3, "settings"),
|
||||
(1, "local"),
|
||||
(0, "empty"),
|
||||
(0, "connecting"),
|
||||
@@ -779,6 +848,7 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
(2, "uninstalled"),
|
||||
(2, "same-version"),
|
||||
(2, "update-complete"),
|
||||
(2, "first-install-complete"),
|
||||
(2, "operation-failed"),
|
||||
] {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
@@ -895,10 +965,23 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
assert!(view.same_update() && view.selection_ready());
|
||||
view.request_operation(cx);
|
||||
assert!(view.confirmation.is_none());
|
||||
} else if name == "update-complete" || name == "operation-failed" {
|
||||
view.chosen_action = "update".into();
|
||||
} else if matches!(
|
||||
name,
|
||||
"update-complete" | "first-install-complete" | "operation-failed"
|
||||
) {
|
||||
view.chosen_action = if name == "first-install-complete" {
|
||||
"install"
|
||||
} else {
|
||||
"update"
|
||||
}
|
||||
.into();
|
||||
view.connected_host = "192.168.1.42:22".into();
|
||||
if name == "first-install-complete" {
|
||||
view.device_state = Some(Installation::default());
|
||||
}
|
||||
|
||||
view.sender
|
||||
.send(Event::Done(if name == "update-complete" {
|
||||
.send(Event::Done(if name != "operation-failed" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("网络连接中断,请重新连接设备后重试。".into())
|
||||
@@ -911,6 +994,19 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
&& view.confirmation.is_none()
|
||||
&& !view.busy
|
||||
);
|
||||
assert_eq!(view.manager_url().is_some(), name != "operation-failed");
|
||||
if name != "operation-failed" {
|
||||
assert_eq!(
|
||||
view.manager_url().as_deref(),
|
||||
Some("http://192.168.1.42:15915/manager")
|
||||
);
|
||||
view.connected_host = "[fd00::42]:22".into();
|
||||
assert_eq!(
|
||||
view.manager_url().as_deref(),
|
||||
Some("http://[fd00::42]:15915/manager")
|
||||
);
|
||||
view.connected_host = "192.168.1.42:22".into();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,117 @@ use anyhow::{Result, bail, ensure};
|
||||
use std::{collections::HashMap, net::IpAddr};
|
||||
use url::Url;
|
||||
|
||||
/// Installer-only download preferences. Explicit HTTP proxy overrides automatic
|
||||
/// discovery; GitHub routing rewrites only trusted GitHub source hosts.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DownloadSettings {
|
||||
pub system: bool,
|
||||
pub http: String,
|
||||
pub github: String,
|
||||
}
|
||||
impl Default for DownloadSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
system: true,
|
||||
http: String::new(),
|
||||
github: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl DownloadSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.http.len() <= 2048 && self.github.len() <= 2048,
|
||||
"代理地址过长"
|
||||
);
|
||||
if !self.http.is_empty() {
|
||||
parse_proxy(&self.http)?;
|
||||
}
|
||||
if !self.github.is_empty() {
|
||||
let url =
|
||||
Url::parse(&self.github).map_err(|_| anyhow::anyhow!("GitHub 代理地址无效"))?;
|
||||
ensure!(
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none(),
|
||||
"GitHub 代理必须是 HTTPS 地址,不能含账号、查询参数或片段"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn route(&self, url: &Url) -> Url {
|
||||
if !self.github.is_empty()
|
||||
&& matches!(
|
||||
url.host_str(),
|
||||
Some(
|
||||
"github.com"
|
||||
| "api.github.com"
|
||||
| "raw.githubusercontent.com"
|
||||
| "objects.githubusercontent.com"
|
||||
| "release-assets.githubusercontent.com"
|
||||
)
|
||||
)
|
||||
&& !url
|
||||
.as_str()
|
||||
.starts_with(&format!("{}/", self.github.trim_end_matches('/')))
|
||||
{
|
||||
Url::parse(&format!("{}/{}", self.github.trim_end_matches('/'), url))
|
||||
.expect("validated GitHub proxy")
|
||||
} else {
|
||||
url.clone()
|
||||
}
|
||||
}
|
||||
pub fn proxy_for(&self, url: &Url) -> Result<Option<ureq::Proxy>> {
|
||||
if !self.http.is_empty() {
|
||||
return parse_proxy(&self.http).map(Some);
|
||||
}
|
||||
if self.system { for_url(url) } else { Ok(None) }
|
||||
}
|
||||
}
|
||||
static DOWNLOAD_SETTINGS: std::sync::OnceLock<std::sync::RwLock<DownloadSettings>> =
|
||||
std::sync::OnceLock::new();
|
||||
fn active_settings() -> &'static std::sync::RwLock<DownloadSettings> {
|
||||
DOWNLOAD_SETTINGS.get_or_init(|| std::sync::RwLock::new(DownloadSettings::default()))
|
||||
}
|
||||
pub fn current() -> DownloadSettings {
|
||||
active_settings().read().unwrap().clone()
|
||||
}
|
||||
fn settings_path() -> Result<std::path::PathBuf> {
|
||||
Ok(
|
||||
directories::ProjectDirs::from("org", "Framely", "Installer")
|
||||
.ok_or_else(|| anyhow::anyhow!("无法确定配置目录"))?
|
||||
.config_dir()
|
||||
.join("download-settings.json"),
|
||||
)
|
||||
}
|
||||
pub fn load() -> Result<DownloadSettings> {
|
||||
let path = settings_path()?;
|
||||
let settings = if path.exists() {
|
||||
serde_json::from_slice::<DownloadSettings>(&std::fs::read(path)?)?
|
||||
} else {
|
||||
DownloadSettings::default()
|
||||
};
|
||||
settings.validate()?;
|
||||
*active_settings().write().unwrap() = settings.clone();
|
||||
Ok(settings)
|
||||
}
|
||||
pub fn save(settings: DownloadSettings) -> Result<()> {
|
||||
settings.validate()?;
|
||||
let path = settings_path()?;
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
|
||||
use std::io::Write;
|
||||
file.write_all(&serde_json::to_vec_pretty(&settings)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(path)?;
|
||||
*active_settings().write().unwrap() = settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Settings {
|
||||
proxies: HashMap<String, String>,
|
||||
@@ -558,3 +669,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_settings_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn default_preserves_system_discovery_and_old_preferences() {
|
||||
assert!(DownloadSettings::default().system);
|
||||
assert!(
|
||||
serde_json::from_str::<DownloadSettings>("{}")
|
||||
.unwrap()
|
||||
.system
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn disabled_system_and_explicit_override() {
|
||||
let mut settings = DownloadSettings {
|
||||
system: false,
|
||||
..Default::default()
|
||||
};
|
||||
let target = Url::parse("https://github.com/a/b").unwrap();
|
||||
assert!(settings.proxy_for(&target).unwrap().is_none());
|
||||
settings.http = "http://127.0.0.1:7890".into();
|
||||
settings.validate().unwrap();
|
||||
assert!(settings.proxy_for(&target).unwrap().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
|
||||
let settings = DownloadSettings {
|
||||
github: "https://proxy.example/prefix/".into(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.validate().unwrap();
|
||||
for host in [
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
] {
|
||||
let original = Url::parse(&format!("https://{host}/a?x=1")).unwrap();
|
||||
let routed = settings.route(&original);
|
||||
assert_eq!(
|
||||
routed.as_str(),
|
||||
format!("https://proxy.example/prefix/{original}")
|
||||
);
|
||||
assert_eq!(settings.route(&routed), routed);
|
||||
}
|
||||
for host in ["github.com.evil.example", "example.org", "proxy.example"] {
|
||||
let url = Url::parse(&format!("https://{host}/a")).unwrap();
|
||||
assert_eq!(settings.route(&url), url);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn invalid_preferences_are_rejected_before_saving() {
|
||||
for github in [
|
||||
"http://proxy.example",
|
||||
"https://proxy.example?q=1",
|
||||
"https://u:p@proxy.example",
|
||||
"https://proxy.example/#x",
|
||||
] {
|
||||
assert!(
|
||||
DownloadSettings {
|
||||
github: github.into(),
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for http in ["file:///tmp/x", "http://[::1]:1234"] {
|
||||
assert!(
|
||||
DownloadSettings {
|
||||
http: http.into(),
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,27 +30,29 @@ pub fn validate_repo(repo: &str) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn agent(url: &url::Url) -> Result<ureq::Agent> {
|
||||
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
|
||||
let mut builder = ureq::AgentBuilder::new()
|
||||
.try_proxy_from_env(false)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.redirects(0);
|
||||
if let Some(proxy) = crate::proxy::for_url(url)? {
|
||||
if let Some(proxy) = settings.proxy_for(url)? {
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
fn get(url: &str) -> Result<ureq::Response> {
|
||||
let settings = crate::proxy::current();
|
||||
let mut url = url::Url::parse(url)?;
|
||||
for _ in 0..=5 {
|
||||
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
|
||||
// Re-evaluate bypass rules for every redirect target.
|
||||
let response = agent(&url)?
|
||||
.get(url.as_str())
|
||||
let routed = settings.route(&url);
|
||||
let response = agent(&routed, &settings)?
|
||||
.get(routed.as_str())
|
||||
.set("User-Agent", "Framely-Installer")
|
||||
.call()?;
|
||||
if (300..400).contains(&response.status()) {
|
||||
url = url.join(response.header("Location").context("重定向缺少地址")?)?;
|
||||
url = routed.join(response.header("Location").context("重定向缺少地址")?)?;
|
||||
} else {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
+25
-12
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::Icon;
|
||||
use gpui_kit::component::{Icon, switch::Switch};
|
||||
use gpui_kit::prelude::FluentBuilder;
|
||||
|
||||
pub(super) const BG: u32 = 0x15171b;
|
||||
@@ -479,6 +479,24 @@ impl Installer {
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
fn settings_page(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
panel()
|
||||
.child(heading("代理设置", "仅影响安装器检查版本与下载,不改变电脑或 Frame 的系统设置。"))
|
||||
.child(div().flex().items_center().justify_between()
|
||||
.child(div().flex().flex_col().gap_2().child("使用系统代理")
|
||||
.child(muted("读取环境变量和系统网络设置;填写 HTTP 代理后优先使用指定地址。")))
|
||||
.child(Switch::new("system-proxy").checked(self.system_proxy)
|
||||
.accessibility_label("使用系统代理").disabled(self.busy)
|
||||
.on_change(cx.listener(|view, checked, _, cx| { view.system_proxy = *checked; cx.notify(); }))))
|
||||
.child(field("HTTP 代理", &self.http_proxy, self.busy))
|
||||
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
|
||||
.child(field("GitHub 代理", &self.github_proxy, self.busy))
|
||||
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
|
||||
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
|
||||
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
|
||||
.disabled(self.busy).on_click(cx.listener(|view, _, _, cx| view.save_proxy(cx)))))
|
||||
.into_any_element()
|
||||
}
|
||||
fn release_page(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let mut list = div()
|
||||
.id("release-list")
|
||||
@@ -1037,7 +1055,8 @@ impl Render for Installer {
|
||||
let body = match self.page {
|
||||
0 => self.device_page(cx),
|
||||
1 => self.release_page(cx),
|
||||
_ => self.maintenance_page(cx),
|
||||
2 => self.maintenance_page(cx),
|
||||
_ => self.settings_page(cx),
|
||||
};
|
||||
let navigation = div()
|
||||
.flex()
|
||||
@@ -1053,6 +1072,7 @@ impl Render for Installer {
|
||||
("连接设备", IconName::Monitor),
|
||||
("选择版本", IconName::Package),
|
||||
("安装与维护", IconName::Wrench),
|
||||
("设置", IconName::Settings),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -1161,18 +1181,11 @@ impl Render for Installer {
|
||||
view.navigate(next, cx);
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
if self.connection.is_some() {
|
||||
let url = format!(
|
||||
"http://{}:15915",
|
||||
self.connected_host
|
||||
.rsplit_once(':')
|
||||
.map(|(h, _)| h)
|
||||
.unwrap_or(&self.connected_host)
|
||||
);
|
||||
} else if self.page == 2 {
|
||||
if let Some(url) = self.manager_url() {
|
||||
footer = footer.child(
|
||||
Button::new("manager")
|
||||
.ghost()
|
||||
.primary()
|
||||
.label("管理面板")
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import java.lang.reflect.*;
|
||||
import java.util.List;
|
||||
public final class FramelyWindow {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if(args.length!=1 || !args[0].matches("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)+")) throw new IllegalArgumentException("Expected package name");
|
||||
Class<?> manager=Class.forName("android.app.ActivityTaskManager");
|
||||
Object service=manager.getDeclaredMethod("getService").invoke(null);
|
||||
Class<?> api=Class.forName("android.app.IActivityTaskManager");
|
||||
Method tasks=api.getMethod("getTasks",int.class);
|
||||
Method mode=api.getMethod("setTaskWindowingMode",int.class,int.class,boolean.class);
|
||||
for(int attempt=0;attempt<20;attempt++) {
|
||||
for(Object task:(List<?>)tasks.invoke(service,32)) {
|
||||
Object top=task.getClass().getField("topActivity").get(task);
|
||||
if(top==null || !args[0].equals(top.getClass().getMethod("getPackageName").invoke(top)))continue;
|
||||
int id=task.getClass().getField("taskId").getInt(task);
|
||||
if(Boolean.TRUE.equals(mode.invoke(service,id,1,true))) {System.out.println("FRAMELY_FULLSCREEN_OK");return;}
|
||||
}
|
||||
Thread.sleep(250);
|
||||
}
|
||||
throw new IllegalStateException("Target task could not enter fullscreen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Android task window helper
|
||||
|
||||
`FramelyWindow.java` uses the Android 11 task service to put only the requested
|
||||
package's foreground task into fullscreen. A startup windowing-mode option alone
|
||||
can leave a restored Lepton task in freeform mode. This helper changes task mode
|
||||
without stopping the game or changing its installation, data, Android image or
|
||||
host compositor. VR launches do not invoke it. It exits after success or a bounded
|
||||
five-second wait; it does not prevent later manual window adjustments.
|
||||
|
||||
The small checked-in DEX is embedded in Framely. `window.sh` supplies ART's runtime
|
||||
environment for `podman exec`; both files are mounted read-only into the container.
|
||||
No APK installation or network access is required at runtime. An unsupported task
|
||||
API fails explicitly instead of guessing Binder transaction numbers.
|
||||
|
||||
Rebuild with `bash tools/build-android-window.sh` (JDK and curl required). This
|
||||
uses Google's D8 8.3.37, pinned by SHA-256. D8 is a build tool and is not shipped.
|
||||
|
||||
## 中文
|
||||
|
||||
`FramelyWindow.java` 通过 Android 11 的任务接口,只将指定包的前台任务切换为全屏。
|
||||
Lepton 恢复已有任务时,仅指定启动窗口模式仍可能留下自由窗口。此助手不停止游戏,
|
||||
不修改安装、数据、Android 镜像或宿主合成器。VR 启动不调用助手。成功后立即退出,
|
||||
等待最多五秒,不持续限制用户之后手动调整窗口。
|
||||
|
||||
小型 DEX 随 Framely 内置;`window.sh` 为 `podman exec` 补充 ART 运行环境。二者以
|
||||
只读方式挂载,运行时无需安装 APK 或联网。不支持的任务接口会明确失败,不猜测
|
||||
Binder 调用编号。
|
||||
|
||||
执行 `bash tools/build-android-window.sh` 可重新生成,需要 JDK 和 curl。
|
||||
构建使用 Google D8 8.3.37,并校验固定 SHA-256;安装包不包含 D8。
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
#!/system/bin/sh
|
||||
set -eu
|
||||
# podman exec lacks init's ART environment. Import only the runtime paths.
|
||||
for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do
|
||||
export "$entry"
|
||||
done
|
||||
export CLASSPATH=/vendor/share/framely-window.dex
|
||||
exec app_process /system/bin FramelyWindow "$@"
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
inline std::chrono::steady_clock::time_point entry_event_time(std::chrono::steady_clock::time_point received,float age){
|
||||
if(!std::isfinite(age)||age<0)return received;
|
||||
return received-std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<float>(age));
|
||||
}
|
||||
|
||||
// Overlay mouse events are authoritative: never synthesize another click from
|
||||
// controller actions. Keep the initiating device/button paired until release.
|
||||
struct EntryHold {
|
||||
using Clock=std::chrono::steady_clock;
|
||||
enum class Action { Idle, Launcher, QuickPanel };
|
||||
static constexpr auto quick_panel_delay=std::chrono::milliseconds(600);
|
||||
static constexpr auto feedback_delay=std::chrono::milliseconds(200);
|
||||
bool pressed=false,fired=false;
|
||||
unsigned device=~0u,button=0;
|
||||
Clock::time_point started{},armed_since{},last_inside{};
|
||||
void cancel(){pressed=false;fired=false;}
|
||||
Action tick(Clock::time_point now,bool available,bool inside){
|
||||
if(!pressed)return Action::Idle;
|
||||
if(!available){cancel();return Action::Idle;}
|
||||
if(inside)last_inside=now;
|
||||
// Brief pointer jitter is tolerated, but leaving cannot activate a hold.
|
||||
if(!inside){if(now-last_inside>std::chrono::milliseconds(150))cancel();return Action::Idle;}
|
||||
if(!fired&&now-armed_since>=quick_panel_delay){fired=true;return Action::QuickPanel;}
|
||||
return Action::Idle;
|
||||
}
|
||||
Action event(bool down,unsigned source,unsigned mouse_button,bool available,bool inside,Clock::time_point now,Clock::time_point received={}){
|
||||
if(down){
|
||||
if(available&&inside&&(!pressed||(source==device&&mouse_button==button))){pressed=true;fired=false;device=source;button=mouse_button;started=now;armed_since=received==Clock::time_point{}?now:received;last_inside=armed_since;}
|
||||
return Action::Idle;
|
||||
}
|
||||
// Steam may omit the device on release; known other devices still cannot release this hold.
|
||||
if(!pressed||(source!=device&&source!=~0u&&device!=~0u)||mouse_button!=button)return Action::Idle;
|
||||
auto action=Action::Idle;
|
||||
if(!fired&&available&&inside)action=now-started>=quick_panel_delay?Action::QuickPanel:Action::Launcher;
|
||||
cancel();return action;
|
||||
}
|
||||
// A confirmed physical release without a matching overlay release must not
|
||||
// become a long press. Prefer short-click behavior over inventing its duration.
|
||||
Action released(bool available,bool inside){
|
||||
const auto action=pressed&&!fired&&available&&inside?Action::Launcher:Action::Idle;
|
||||
cancel();return action;
|
||||
}
|
||||
float progress(Clock::time_point now)const{
|
||||
if(!pressed||now-armed_since<feedback_delay)return 0.f;
|
||||
const float elapsed=std::chrono::duration<float>(now-armed_since-feedback_delay).count();
|
||||
const float remaining=std::chrono::duration<float>(quick_panel_delay-feedback_delay).count();
|
||||
// A tiny nonzero value reveals the track at 100ms without jumping to 1/6 full.
|
||||
return std::clamp(elapsed/remaining,.001f,1.f);
|
||||
}
|
||||
};
|
||||
|
||||
inline void entry_progress_border(std::vector<uint8_t>& pixels,float progress){
|
||||
if(progress<=0||pixels.size()!=128*128*4)return;
|
||||
constexpr float pi=3.14159265358979323846f,half=61.5f,radius=7.1f;
|
||||
constexpr float extent=half-radius,arc=pi*.5f*radius,perimeter=8*extent+4*arc;
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
|
||||
float dx=x-63.5f,dy=y-63.5f,qx=std::abs(dx)-extent,qy=std::abs(dy)-extent;
|
||||
const float distance=std::hypot(std::max(qx,0.f),std::max(qy,0.f))+std::min(std::max(qx,qy),0.f)-radius;
|
||||
const float coverage=std::clamp(2.f-std::abs(distance),0.f,1.f);
|
||||
if(coverage<=0)continue;
|
||||
// Arc length along the inset rounded rectangle, starting at top center.
|
||||
float along;
|
||||
if(dx>=extent&&dy<=-extent)along=extent+(std::atan2(dy+extent,dx-extent)+pi*.5f)*radius;
|
||||
else if(dx>=extent&&dy>=extent)along=3*extent+arc+std::atan2(dy-extent,dx-extent)*radius;
|
||||
else if(dx<=-extent&&dy>=extent)along=5*extent+2*arc+(std::atan2(dy-extent,dx+extent)-pi*.5f)*radius;
|
||||
else if(dx<=-extent&&dy<=-extent)along=7*extent+3*arc+(std::atan2(dy+extent,dx+extent)+pi)*radius;
|
||||
else if(dy<-extent)along=dx>=0?dx:perimeter+dx;
|
||||
else if(dx>extent)along=2*extent+arc+dy;
|
||||
else if(dy>extent)along=4*extent+2*arc-dx;
|
||||
else along=6*extent+3*arc-dy;
|
||||
const float alpha=coverage*(along<=progress*perimeter?1.f:.12f);
|
||||
const int blue[3]={147,197,237};const int i=(y*128+x)*4;
|
||||
for(int c=0;c<3;c++)pixels[i+c]=uint8_t(pixels[i+c]+(blue[c]-pixels[i+c])*alpha);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Session-owned Steam Input/OpenVR -> Android bridge with reverse rumble.
|
||||
#include "openvr.h"
|
||||
#include <linux/uinput.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <filesystem>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
static volatile sig_atomic_t stopped=0;
|
||||
static void stop(int){stopped=1;}
|
||||
#include "gamepad_mapping.h"
|
||||
#include "gamepad_steam.h"
|
||||
#include "gamepad_feedback.h"
|
||||
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
|
||||
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
int main(int argc,char**argv){
|
||||
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
|
||||
// follows that thread's lifetime, so it destroys the pad as soon as prepare
|
||||
// returns. The session-owned stdin pipe and parent process own this helper.
|
||||
const auto parent=getppid();
|
||||
const bool steamMode=std::getenv("FRAMELY_GAMEPAD_SOURCE")&&std::string(std::getenv("FRAMELY_GAMEPAD_SOURCE"))=="steam";
|
||||
SteamGamepad steam;
|
||||
if(steamMode&&!steam.start(std::getenv("FRAMELY_STEAM_SDL_LIBRARY"),std::getenv("FRAMELY_STEAM_GAMEPAD_INFO"))){fprintf(stderr,"Steam Input SDL3 runtime unavailable; select Frame direct input in APK settings.\n");return 1;}
|
||||
bool rumbleEnabled=!std::getenv("FRAMELY_GAMEPAD_RUMBLE")||std::string(std::getenv("FRAMELY_GAMEPAD_RUMBLE"))!="0";
|
||||
auto milliseconds=[](){return uint64_t(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());};
|
||||
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
|
||||
vr::EVRInitError error=vr::VRInitError_None;if(!steamMode)vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
|
||||
// Different live containers must not share SteamVR's generated executable
|
||||
// identity. Otherwise a second helper replaces the first app's registered PID.
|
||||
bool registered=false;
|
||||
// Steam Input already represents Steam's configured output. Registering an
|
||||
// independent OpenVR action app here suppresses SDL's virtual-gamepad
|
||||
// enumeration in this process on Frame.
|
||||
auto shutdown=[&](){if(!steamMode){if(registered)vr::VRApplications()->RemoveApplicationManifest(argv[2]);vr::VR_Shutdown();}};
|
||||
if(!steamMode&&argc==4){
|
||||
if(vr::VRApplications()->AddApplicationManifest(argv[2],true)!=vr::VRApplicationError_None){fprintf(stderr,"Gamepad application registration failed.\n");shutdown();return 1;}
|
||||
registered=true;
|
||||
auto identity=vr::VRApplications()->IdentifyApplication(getpid(),argv[3]);if(identity!=vr::VRApplicationError_None){fprintf(stderr,"Gamepad application identity failed: %d (%s).\n",identity,vr::VRApplications()->GetApplicationsErrorNameFromEnum(identity));shutdown();return 1;}
|
||||
}
|
||||
auto*input=steamMode?nullptr:vr::VRInput();vr::VRActiveActionSet_t set{};
|
||||
bool valid=steamMode||(input->SetActionManifestPath(argv[1])==vr::VRInputError_None&&input->GetActionSetHandle("/actions/framely_gamepad",&set.ulActionSet)==vr::VRInputError_None);
|
||||
const char*names[]={"right_a","right_b","right_x","right_y","left_bumper","right_bumper","left_menu","right_menu","left_stickclick","right_stickclick","left_thumbstick","right_thumbstick","left_trigger","right_trigger","left_dpad_left","left_dpad_right","left_dpad_up","left_dpad_down"};
|
||||
vr::VRActionHandle_t handles[18]{};
|
||||
for(int k=0;!steamMode&&k<18;k++)valid= input->GetActionHandle((std::string("/actions/framely_gamepad/in/")+names[k]).c_str(),&handles[k])==vr::VRInputError_None&&valid;
|
||||
if(!valid){fprintf(stderr,"Invalid gamepad action bindings.\n");shutdown();return 1;}
|
||||
vr::VRActionHandle_t haptics[2]{};
|
||||
for(int k=0;!steamMode&&k<2;k++)input->GetActionHandle(k?"/actions/framely_gamepad/out/right_haptic":"/actions/framely_gamepad/out/left_haptic",&haptics[k]);
|
||||
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
|
||||
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
|
||||
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
|
||||
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
|
||||
char sysname[128]{};ok=ioctl(fd,UI_GET_SYSNAME(sizeof sysname),sysname)>=0&&ok;
|
||||
std::string node;
|
||||
for(int tries=0;ok&&tries<100&&node.empty();tries++){std::error_code ec;for(auto&entry:std::filesystem::directory_iterator(std::string("/sys/class/input/")+sysname,ec)){auto name=entry.path().filename().string();if(name.rfind("event",0)==0)node="/dev/input/"+name;}if(node.empty())std::this_thread::sleep_for(std::chrono::milliseconds(20));}
|
||||
if(!ok||node.empty()){fprintf(stderr,"Could not create the virtual gamepad.\n");ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 1;}
|
||||
int witness=-1;for(int tries=0;tries<100&&witness<0;tries++){witness=open(node.c_str(),O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(witness<0)std::this_thread::sleep_for(std::chrono::milliseconds(20));}
|
||||
if(witness<0){perror("Gamepad event access");ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
|
||||
bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;}if(commands.size()>128)break;}
|
||||
const auto now=milliseconds();
|
||||
// Android may upload effects even while unfocused. Always acknowledge the
|
||||
// kernel requests; only playback is gated by foreground ownership.
|
||||
for(int k=0;k<64;k++){input_event e{};if(read(fd,&e,sizeof e)!=sizeof e)break;
|
||||
if(e.type==EV_UINPUT&&e.code==UI_FF_UPLOAD){uinput_ff_upload q{};q.request_id=e.value;if(ioctl(fd,UI_BEGIN_FF_UPLOAD,&q)==0){q.retval=feedback.upload(q.effect);ioctl(fd,UI_END_FF_UPLOAD,&q);}}
|
||||
else if(e.type==EV_UINPUT&&e.code==UI_FF_ERASE){uinput_ff_erase q{};q.request_id=e.value;if(ioctl(fd,UI_BEGIN_FF_ERASE,&q)==0){q.retval=feedback.erase(q.effect_id);ioctl(fd,UI_END_FF_ERASE,&q);}}
|
||||
else if(e.type==EV_FF)feedback.play(e.code,e.value,now);
|
||||
}
|
||||
int state[18]{};bool inputAvailable=steamMode;
|
||||
// If Android releases its exclusive claim (e.g. InputReader restart),
|
||||
// pause until it claims the node again. Do not feed other host consumers.
|
||||
bool claimed=false;if(enabled){if(ioctl(witness,EVIOCGRAB,1)==0)ioctl(witness,EVIOCGRAB,0);else claimed=errno==EBUSY;}
|
||||
if(steamMode){auto next=steam.read(now,enabled&&claimed);std::copy(next.begin(),next.end(),state);if(source!=steam.source()){feedback.stop();source=steam.source();lastRumble={};}}
|
||||
if(!steamMode&&enabled&&claimed&&input->UpdateActionState(&set,sizeof set,1)==vr::VRInputError_None){
|
||||
const auto leftIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
const auto rightIndex=vr::VRSystem()->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
|
||||
const bool left=leftIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(leftIndex);
|
||||
const bool right=rightIndex!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRSystem()->IsTrackedDeviceConnected(rightIndex);
|
||||
if((frameConnected[0]&&!left)||(frameConnected[1]&&!right))feedback.stop();frameConnected[0]=left;frameConnected[1]=right;inputAvailable=left||right;
|
||||
bool buttons[18]{};for(int k=0;k<18;k++){if(k>=10&&k<=13)continue;vr::InputDigitalActionData_t data{};if(input->GetDigitalActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)==vr::VRInputError_None)buttons[k]=(k==4||k==6||k==8||k>=14?left:right)&&data.bActive&&data.bState;}
|
||||
for(int k=0;k<10;k++)state[k]=buttons[k];
|
||||
for(int k=10;k<=13;k++){if(!(k==10||k==12?left:right))continue;vr::InputAnalogActionData_t data{};if(input->GetAnalogActionData(handles[k],&data,sizeof data,vr::k_ulInvalidInputValueHandle)!=vr::VRInputError_None||!data.bActive||!std::isfinite(data.x)||!std::isfinite(data.y))continue;if(k<=11){state[10+(k-10)*2]=std::lround(std::clamp(data.x,-1.f,1.f)*32767);state[11+(k-10)*2]=std::lround(std::clamp(-data.y,-1.f,1.f)*32767);}else state[14+k-12]=std::lround(std::clamp(data.x,0.f,1.f)*255);}
|
||||
state[16]=int(buttons[15])-int(buttons[14]);state[17]=int(buttons[17])-int(buttons[16]);
|
||||
}
|
||||
const auto vibration=feedback.value(now,enabled&&claimed&&rumbleEnabled&&inputAvailable);
|
||||
if(vibration!=lastRumble||((vibration[0]||vibration[1])&&now-rumbleAt>=100)){
|
||||
if(steamMode)steam.rumble(vibration[0],vibration[1]);
|
||||
lastRumble=vibration;rumbleAt=now;
|
||||
}
|
||||
if(!steamMode&&(vibration[0]||vibration[1]))for(int k=0;k<2;k++)if(haptics[k]!=vr::k_ulInvalidActionHandle&&vibration[k])input->TriggerHapticVibrationAction(haptics[k],0,.012f,100.f,float(vibration[k])/65535.f,vr::k_ulInvalidInputValueHandle);
|
||||
if(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(8));
|
||||
}
|
||||
steam.stop();feedback.stop();int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <linux/input.h>
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
// Kernel-owned effect IDs. Keep upload/erase acknowledgements independent of
|
||||
// focus so Android's InputReader cannot block while the game is in background.
|
||||
class GamepadFeedback {
|
||||
struct Slot { ff_effect effect{}; bool uploaded=false,playing=false; uint64_t start=0,end=0,delay=0,period=0; };
|
||||
std::array<Slot,32> slots{};
|
||||
public:
|
||||
static constexpr int capacity=32;
|
||||
int upload(const ff_effect& effect) {
|
||||
if(effect.id<0||effect.id>=capacity||effect.type!=FF_RUMBLE)return -EINVAL;
|
||||
auto& s=slots[effect.id];s.effect=effect;s.uploaded=true;return 0;
|
||||
}
|
||||
int erase(int id){if(id<0||id>=capacity)return -EINVAL;slots[id]={};return 0;}
|
||||
void play(int id,int count,uint64_t now){if(id<0||id>=capacity)return;auto& s=slots[id];s.playing=count>0&&s.uploaded;if(!s.playing)return;s.start=now;s.delay=std::min<unsigned>(s.effect.replay.delay,5000);s.period=s.delay+std::min<unsigned>(s.effect.replay.length,5000);s.end=now+std::min<uint64_t>(10000,s.period*std::min(count,32));}
|
||||
void stop(){for(auto& s:slots)s.playing=false;}
|
||||
std::array<uint16_t,2> value(uint64_t now,bool allowed){
|
||||
std::array<uint16_t,2> v{};if(!allowed){stop();return v;}
|
||||
for(auto& s:slots){if(!s.playing)continue;if(now>=s.end){s.playing=false;continue;}if(now<s.start||!s.period||(now-s.start)%s.period<s.delay)continue;v[0]=std::max<uint16_t>(v[0],std::min<unsigned>(s.effect.u.rumble.strong_magnitude,32768));v[1]=std::max<uint16_t>(v[1],std::min<unsigned>(s.effect.u.rumble.weak_magnitude,32768));}return v;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// Bionic preload: exclusively claim only Framely's mounted virtual event node.
|
||||
// Raw AArch64 syscalls avoid a glibc dependency inside Android.
|
||||
#define _GNU_SOURCE
|
||||
#include <stdarg.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
extern int *__errno(void);
|
||||
static long call(long nr,long a,long b,long c,long d){register long x0 __asm__("x0")=a;register long x1 __asm__("x1")=b;register long x2 __asm__("x2")=c;register long x3 __asm__("x3")=d;register long x8 __asm__("x8")=nr;__asm__ volatile("svc 0":"+r"(x0):"r"(x1),"r"(x2),"r"(x3),"r"(x8):"memory");return x0;}
|
||||
static int same(const char*a,const char*b){while(*a&&*a==*b){a++;b++;}return *a==*b;}
|
||||
static int opened(int dir,const char*p,int flags,int mode){long fd=call(56,dir,(long)p,flags,mode);if(fd<0){*__errno()=-fd;return -1;}if(same(p,"/dev/input/event250")){long r=call(29,fd,EVIOCGRAB,1,0);if(r==0){long ready=call(56,-100,(long)"/framely-gamepad-ready/ready",O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}}return fd;}
|
||||
int open(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int open64(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int openat(int dir,const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(dir,p,flags,mode);}
|
||||
int __open_2(const char*p,int flags){return opened(-100,p,flags,0);}
|
||||
int __openat_2(int dir,const char*p,int flags){return opened(dir,p,flags,0);}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <linux/input-event-codes.h>
|
||||
// Action order: A, B, X, Y, LB, RB, View, Menu, LS, RS.
|
||||
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
|
||||
// aliases WEST/NORTH do not match these letter names.
|
||||
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
#include <dlfcn.h>
|
||||
#include <cstdint>
|
||||
#include <linux/input.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
inline constexpr unsigned framely_pad_vendor=0x0001,framely_pad_product=0xf001;
|
||||
struct SteamPadInfo {int slot=-1;unsigned vendor=0,product=0;bool identified=false;};
|
||||
inline std::vector<SteamPadInfo> read_steam_pad_info(std::istream& stream){
|
||||
std::vector<SteamPadInfo> rows;std::string line;size_t bytes=0;
|
||||
while(std::getline(stream,line)){bytes+=line.size()+1;if(bytes>65536)return {};int slot=-1;if(std::sscanf(line.c_str(),"[slot %d]",&slot)==1){if(slot<0||slot>63)return {};rows.push_back({slot});continue;}if(rows.empty())continue;auto& p=rows.back();unsigned value=0;if(std::sscanf(line.c_str(),"VID=%x",&value)==1)p.vendor=value;else if(std::sscanf(line.c_str(),"PID=%x",&value)==1){p.product=value;p.identified=true;}}
|
||||
return rows;
|
||||
}
|
||||
inline bool allowed_steam_slot(const std::vector<SteamPadInfo>& rows,int slot){return std::any_of(rows.begin(),rows.end(),[&](auto p){return p.slot==slot&&p.identified&&p.vendor!=0&&!(p.vendor==framely_pad_vendor&&p.product==framely_pad_product);});}
|
||||
inline std::array<int,18> steam_pad_state(const std::array<bool,15>& buttons,const std::array<int,6>& axes){
|
||||
std::array<int,18> state{};const int indices[]={0,1,2,3,9,10,4,6,7,8};for(int k=0;k<10;k++)state[k]=buttons[indices[k]];
|
||||
for(int k=0;k<4;k++)state[10+k]=std::clamp(axes[k],-32768,32767);
|
||||
for(int k=0;k<2;k++)state[14+k]=std::clamp(axes[4+k],0,32767)*255/32767;
|
||||
state[16]=int(buttons[14])-int(buttons[13]);state[17]=int(buttons[12])-int(buttons[11]);return state;
|
||||
}
|
||||
class SteamGamepad {
|
||||
void* library=nullptr;
|
||||
using Pad=void*;
|
||||
bool (*init)(uint32_t)=nullptr;void (*quit)()=nullptr;void (*update)()=nullptr;void (*pump)()=nullptr;void (*flush)(uint32_t,uint32_t)=nullptr;
|
||||
uint32_t* (*get)(int*)=nullptr;void (*freeMem)(void*)=nullptr;Pad (*openPad)(uint32_t)=nullptr;void (*closePad)(Pad)=nullptr;
|
||||
const char* (*path)(uint32_t)=nullptr;bool (*button)(Pad,int)=nullptr;int16_t (*axis)(Pad,int)=nullptr;
|
||||
bool (*rumblePad)(Pad,uint16_t,uint16_t,uint32_t)=nullptr;
|
||||
struct Entry {uint32_t id;Pad pad;std::array<int,18> previous{};};std::vector<Entry> pads;
|
||||
std::string infoPath;uint32_t selected=0,vibrating=0;uint64_t scanned=0;bool scanning=false,initialized=false;
|
||||
template<class T>bool symbol(T& target,const char* name){target=reinterpret_cast<T>(dlsym(library,name));return target!=nullptr;}
|
||||
bool valid(uint32_t id,const std::vector<SteamPadInfo>& info){
|
||||
const char* p=path(id);if(!p||std::string(p).rfind("/dev/input/event",0)!=0)return false;
|
||||
const std::string suffix=std::string(p).substr(16);if(suffix.empty()||!std::all_of(suffix.begin(),suffix.end(),[](char c){return c>='0'&&c<='9';}))return false;
|
||||
int fd=::open(p,O_RDONLY|O_NONBLOCK|O_CLOEXEC);if(fd<0)return false;input_id device{};char name[128]{};const bool ok=ioctl(fd,EVIOCGID,&device)==0&&device.vendor==0x28de&&device.product==0x11ff&&ioctl(fd,EVIOCGNAME(sizeof name),name)>0;::close(fd);if(!ok)return false;
|
||||
const char* at=std::strstr(name,"pad ");int slot=-1;return at&&std::sscanf(at+4,"%d",&slot)==1&&allowed_steam_slot(info,slot);
|
||||
}
|
||||
void scan(uint64_t now){
|
||||
if(scanning&&now-scanned<500)return;scanning=true;scanned=now;std::ifstream f(infoPath);auto info=read_steam_pad_info(f);
|
||||
int count=0;auto* ids=get(&count);std::vector<uint32_t> validIds;
|
||||
if(ids&&count>=0&&count<=64)for(int k=0;k<count;k++)if(valid(ids[k],info))validIds.push_back(ids[k]);freeMem(ids);
|
||||
for(auto it=pads.begin();it!=pads.end();){if(std::find(validIds.begin(),validIds.end(),it->id)==validIds.end()){if(vibrating==it->id){rumblePad(it->pad,0,0,0);vibrating=0;}if(selected==it->id)selected=0;closePad(it->pad);it=pads.erase(it);}else ++it;}
|
||||
for(auto id:validIds)if(std::none_of(pads.begin(),pads.end(),[&](auto p){return p.id==id;})){auto p=openPad(id);if(p)pads.push_back({id,p});}
|
||||
}
|
||||
public:
|
||||
uint32_t source()const{return selected;}
|
||||
bool start(const char* lib,const char* info){
|
||||
if(!lib||!info)return false;infoPath=info;library=dlopen(lib,RTLD_NOW|RTLD_LOCAL);if(!library)return false;
|
||||
bool ok=symbol(init,"SDL_Init")&&symbol(quit,"SDL_Quit")&&symbol(update,"SDL_UpdateGamepads")&&symbol(pump,"SDL_PumpEvents")&&symbol(flush,"SDL_FlushEvents")&&symbol(get,"SDL_GetGamepads")&&symbol(freeMem,"SDL_free")&&symbol(openPad,"SDL_OpenGamepad")&&symbol(closePad,"SDL_CloseGamepad")&&symbol(path,"SDL_GetGamepadPathForID")&&symbol(button,"SDL_GetGamepadButton")&&symbol(axis,"SDL_GetGamepadAxis")&&symbol(rumblePad,"SDL_RumbleGamepad");if(!ok)return false;
|
||||
setenv("SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD","1",1);setenv("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS","1",1);setenv("SteamVirtualGamepadInfo",info,1);
|
||||
// Only Steam's post-configuration evdev pads are accepted. HIDAPI must not
|
||||
// additionally open the physical devices or bypass Steam's mappings.
|
||||
setenv("SDL_JOYSTICK_HIDAPI","0",1);setenv("SDL_GAMECONTROLLER_IGNORE_DEVICES","0x0001/0xf001",1);
|
||||
initialized=init(0x2000);return initialized;
|
||||
}
|
||||
std::array<int,18> read(uint64_t now,bool enabled){
|
||||
std::array<int,18> result{};if(!initialized)return result;pump();flush(0,0xffff);update();scan(now);uint32_t next=selected;
|
||||
for(auto& p:pads){std::array<bool,15> buttons{};std::array<int,6> axes{};for(int k=0;k<15;k++)buttons[k]=button(p.pad,k);for(int k=0;k<6;k++)axes[k]=axis(p.pad,k);auto state=steam_pad_state(buttons,axes);
|
||||
bool activity=false;for(int k=0;k<10;k++)activity|=state[k]&&!p.previous[k];for(int k=10;k<16;k++)activity|=std::abs(state[k])>(k<14?8000:40)&&std::abs(state[k]-p.previous[k])>(k<14?1500:15);for(int k=16;k<18;k++)activity|=state[k]!=0&&state[k]!=p.previous[k];p.previous=state;
|
||||
if(enabled&&activity)next=p.id;
|
||||
}
|
||||
if(!next&&!pads.empty())next=pads.front().id;
|
||||
if(next!=selected){stop();selected=next;return result;}
|
||||
if(enabled)for(auto& p:pads)if(p.id==selected)result=p.previous;return result;
|
||||
}
|
||||
bool rumble(uint16_t low,uint16_t high){if(!low&&!high){stop();return true;}for(auto& p:pads)if(p.id==selected){const bool ok=rumblePad(p.pad,low,high,250);if(ok)vibrating=p.id;return ok;}return false;}
|
||||
// Cancel only feedback issued by this bridge, never unrelated Steam rumble.
|
||||
void stop(){for(auto& p:pads)if(p.id==vibrating)rumblePad(p.pad,0,0,0);vibrating=0;}
|
||||
~SteamGamepad(){if(initialized){stop();for(auto& p:pads)closePad(p.pad);quit();}if(library)dlclose(library);}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include "launcher_gaze.h"
|
||||
#include <algorithm>
|
||||
struct GazeAngles {float yaw=0,pitch=0;};
|
||||
inline bool gaze_head_angles(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& world,GazeAngles& out){
|
||||
float local[3]{};for(int i=0;i<3;i++)for(int r=0;r<3;r++){if(!std::isfinite(head.m[r][i])||!std::isfinite(world.v[r]))return false;local[i]+=head.m[r][i]*world.v[r];}
|
||||
if(local[0]*local[0]+local[1]*local[1]+local[2]*local[2]<1e-8)return false;
|
||||
out={std::atan2(local[0],-local[2]),std::atan2(local[1],std::hypot(local[0],local[2]))};return true;
|
||||
}
|
||||
inline vr::VROverlayIntersectionParams_t gaze_world_ray(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& origin,GazeAngles a){
|
||||
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;ray.vSource=origin;
|
||||
float local[]{std::sin(a.yaw)*std::cos(a.pitch),std::sin(a.pitch),-std::cos(a.yaw)*std::cos(a.pitch)};
|
||||
for(int r=0;r<3;r++)for(int i=0;i<3;i++)ray.vDirection.v[r]+=head.m[r][i]*local[i];return ray;
|
||||
}
|
||||
inline bool gaze_angles_to_point(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& origin,const vr::HmdVector3_t& target,GazeAngles& angles){
|
||||
vr::HmdVector3_t direction{};for(int r=0;r<3;r++)direction.v[r]=target.v[r]-origin.v[r];return gaze_head_angles(head,direction,angles);
|
||||
}
|
||||
inline GazeAngles gaze_correct(GazeAngles raw,const double (&m)[6]){return {float(m[0]*raw.yaw+m[1]*raw.pitch+m[2]),float(m[3]*raw.yaw+m[4]*raw.pitch+m[5])};}
|
||||
// Invert the runtime's actual curved-dashboard intersection. Its coordinate
|
||||
// transform API alone may describe a flat surface. Sampling this inverse is
|
||||
// needed only when the target or window changes; cache the returned world point.
|
||||
template<class Sample> bool gaze_target_inverse(GazeAngles seed,float u,float v,Sample sample,GazeAngles& out){
|
||||
auto current=seed;float x=0,y=0;if(!sample(current,x,y))return false;
|
||||
for(int n=0;n<18;n++){
|
||||
float ex=x-u,ey=y-v;if(std::hypot(ex,ey)<.0003f){out=current;return true;}
|
||||
constexpr float delta=.0005f;float xx,xy,yx,yy;
|
||||
if(!sample({current.yaw+delta,current.pitch},xx,xy)||!sample({current.yaw,current.pitch+delta},yx,yy))return false;
|
||||
float a=(xx-x)/delta,b=(yx-x)/delta,c=(xy-y)/delta,d=(yy-y)/delta,det=a*d-b*c;if(!std::isfinite(det)||std::abs(det)<1e-6)return false;
|
||||
float dx=std::clamp((d*ex-b*ey)/det,-.15f,.15f),dy=std::clamp((-c*ex+a*ey)/det,-.15f,.15f);bool improved=false;
|
||||
for(float factor=1;factor>=.03125f;factor*=.5f){GazeAngles next{current.yaw-factor*dx,current.pitch-factor*dy};float nx,ny;if(sample(next,nx,ny)&&std::hypot(nx-u,ny-v)<std::hypot(ex,ey)){current=next;x=nx;y=ny;improved=true;break;}}
|
||||
if(!improved)return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+228
-38
@@ -9,9 +9,13 @@
|
||||
#include "dock_uv_geometry.h"
|
||||
#include "menu_geometry.h"
|
||||
#include "menu_input.h"
|
||||
#include "entry_hold.h"
|
||||
#include "launcher_input.h"
|
||||
#include "launcher_fade.h"
|
||||
#include "launcher_priority.h"
|
||||
#include "launcher_pointer.h"
|
||||
#include "launcher_gaze.h"
|
||||
#include "gaze_geometry.h"
|
||||
#include "page_scroll.h"
|
||||
#include "page_scroll_injection.h"
|
||||
#include "include/capi/cef_load_handler_capi.h"
|
||||
@@ -26,6 +30,7 @@
|
||||
#include "keyboard_input.h"
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include "ui_feedback.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
@@ -41,6 +46,7 @@
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <deque>
|
||||
#include <set>
|
||||
#include <unistd.h>
|
||||
|
||||
using json=nlohmann::json;
|
||||
@@ -54,6 +60,8 @@ static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool launcher_open=false;
|
||||
static bool ui_haptics=true,ui_sounds=true;
|
||||
static SteamUiSounds ui_audio;
|
||||
static LauncherFade launcher_fade;
|
||||
static std::chrono::steady_clock::time_point launcher_close_at{};
|
||||
static LauncherDismissGuard launcher_dismiss;
|
||||
@@ -64,12 +72,35 @@ static PageScrollStick page_scroll_stick;
|
||||
static vr::VROverlayHandle_t page_scroll_target=0;
|
||||
static std::chrono::steady_clock::time_point launcher_outside_at{};
|
||||
static bool launcher_selected=false;
|
||||
static uint64_t launcher_session=0;
|
||||
static std::atomic<bool> launcher_prioritize_ui{true};
|
||||
static std::atomic<bool> launcher_priority_active{false};
|
||||
static std::string launcher_previous_dashboard;
|
||||
static std::set<std::string> dashboard_keys;
|
||||
static bool launcher_restore_previous=true,launcher_previous_menu=false;
|
||||
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
|
||||
static std::atomic<bool> launcher_primary{true};
|
||||
static EntryHold entry_hold;
|
||||
static uint64_t entry_source_buttons=0;
|
||||
static std::map<unsigned,uint64_t> entry_previous_buttons;
|
||||
static bool entry_actions_ready=false;
|
||||
static vr::VRActionSetHandle_t entry_set=0;
|
||||
static vr::VRActionSetHandle_t entry_set=0,gaze_set=0;
|
||||
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
|
||||
static vr::VRInputValueHandle_t hands[2]{};
|
||||
static vr::VRActionHandle_t gaze_action=0,confirm_action=0,gaze_haptic_action=0;
|
||||
static LauncherGazePress gaze_press;
|
||||
static bool gaze_active=false,launcher_calibrating=false;
|
||||
static json gaze_correction=nullptr,gaze_preview=nullptr;
|
||||
static std::string gaze_target_id;
|
||||
static float gaze_target_u=.5f,gaze_target_v=.5f;
|
||||
static vr::HmdVector3_t gaze_target_world{};
|
||||
static bool gaze_target_cached=false;
|
||||
static bool gaze_matrix(const json& config,double (&m)[6]){
|
||||
if(!config.is_object()||config.value("space",std::string{})!="headAngles"||!config.contains("matrix")||!config["matrix"].is_array()||config["matrix"].size()!=6)return false;
|
||||
for(int i=0;i<6;i++){if(!config["matrix"][i].is_number())return false;m[i]=config["matrix"][i].get<double>();if(!std::isfinite(m[i])||std::abs(m[i])>4)return false;}
|
||||
double det=m[0]*m[4]-m[1]*m[3];return det>=.25&&det<=4&&std::abs(m[2])<=.5&&std::abs(m[5])<=.5;
|
||||
}
|
||||
static int gaze_feedback_hand=1;
|
||||
static std::chrono::steady_clock::time_point gaze_sent{},gaze_feedback_at{};
|
||||
static EntryTrigger entry_buttons[2][2];
|
||||
static EntryAnalogTrigger entry_analog[2];
|
||||
static EntryPointer entry_pointer;
|
||||
@@ -93,7 +124,7 @@ template<class T> struct Handler {
|
||||
};
|
||||
struct View {
|
||||
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{};
|
||||
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
|
||||
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
|
||||
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
|
||||
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
@@ -138,11 +169,39 @@ static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)thr
|
||||
// runtime's overlay mouse space, before conversion to CEF's top-left origin.
|
||||
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
|
||||
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
|
||||
static std::vector<uint8_t> dock_icon(bool hover,bool active){
|
||||
auto source=icon(hover,active);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
static std::vector<uint8_t> dock_icon(bool hover,bool active,float progress=0){
|
||||
auto source=icon(hover,active);entry_progress_border(source,progress);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
|
||||
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
|
||||
return canvas;
|
||||
}
|
||||
// Keep texture storage alive while OpenVR samples the preceding frame. Raw
|
||||
// overlay replacement during a hold can disturb the image and hover target.
|
||||
struct DockIconTexture {
|
||||
GLuint textures[2]{};int slot=0;std::vector<uint8_t> upload;
|
||||
void init(){
|
||||
glGenTextures(2,textures);
|
||||
for(auto texture:textures){
|
||||
glBindTexture(GL_TEXTURE_2D,texture);
|
||||
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
|
||||
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,dock_canvas_width,dock_canvas_height,0,GL_RGBA,GL_UNSIGNED_BYTE,nullptr);
|
||||
}
|
||||
}
|
||||
bool submit(vr::VROverlayHandle_t overlay,const std::vector<uint8_t>& pixels){
|
||||
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
|
||||
const int next=1-slot;glBindTexture(GL_TEXTURE_2D,textures[next]);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
|
||||
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
|
||||
// Raster icons use top-left rows; OpenVR OpenGL textures use bottom-left.
|
||||
merge_paint(upload,pixels.data(),dock_canvas_width,dock_canvas_height,0,static_cast<const cef_rect_t*>(nullptr),true);
|
||||
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,dock_canvas_width,dock_canvas_height,GL_RGBA,GL_UNSIGNED_BYTE,upload.data());
|
||||
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(textures[next])),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
|
||||
const auto error=overlays->SetOverlayTexture(overlay,&texture);glFlush();
|
||||
if(error){std::cerr<<"Dock texture submission failed "<<error<<"\n";return false;}
|
||||
slot=next;return true;
|
||||
}
|
||||
void destroy(){glDeleteTextures(2,textures);}
|
||||
};
|
||||
static vr::VROverlayHandle_t active_main_window(){
|
||||
static auto refreshed=std::chrono::steady_clock::time_point{};static vr::VROverlayHandle_t selected=0;
|
||||
auto now=std::chrono::steady_clock::now();if(now-refreshed<std::chrono::milliseconds(500)&&selected&&overlays->IsActiveDashboardOverlay(selected))return selected;
|
||||
@@ -150,9 +209,10 @@ static vr::VROverlayHandle_t active_main_window(){
|
||||
overlays->FindOverlay("valve.steam.gamepadui.main",&selected);
|
||||
return selected;
|
||||
}
|
||||
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last){
|
||||
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last,bool activation=false){
|
||||
auto now=std::chrono::steady_clock::now();if(now-last<std::chrono::milliseconds(80)||!overlays->IsOverlayVisible(overlay)||!overlays->IsHoverTargetOverlay(overlay))return;
|
||||
auto e=overlays->TriggerLaserMouseHapticVibration(overlay,.012f,120.f,.15f);if(!e)last=now;else std::cerr<<"Hover haptic failed "<<overlay<<" "<<e<<"\n";
|
||||
last=now;if(ui_sounds)ui_audio.play(activation);
|
||||
if(ui_haptics&&!activation){auto e=overlays->TriggerLaserMouseHapticVibration(overlay,.012f,120.f,.15f);if(e)std::cerr<<"Hover haptic failed "<<overlay<<" "<<e<<"\n";}
|
||||
}
|
||||
static void laser_cursor(vr::VROverlayHandle_t overlay,const vr::VREvent_t& event){
|
||||
if(event.eventType==vr::VREvent_MouseMove){vr::HmdVector2_t position{{event.data.mouse.x,event.data.mouse.y}};overlays->SetOverlayCursorPositionOverride(overlay,&position);}
|
||||
@@ -269,13 +329,42 @@ static bool launcher_dock_interaction(){
|
||||
if(launcher_dock.holds(now)){launcher_outside_at={};return true;}
|
||||
return false;
|
||||
}
|
||||
static void hide_launcher(){
|
||||
static std::string selected_dashboard(){
|
||||
std::set<std::string> keys=dashboard_keys;keys.insert("valve.steam.gamepadui.main");keys.insert("system.systemui");
|
||||
for(auto& [key,v]:views)if(v->dashboard&&!v->closing)keys.insert(key);
|
||||
for(auto& key:keys){if(key=="launcher")continue;vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key.c_str(),&handle)&&overlays->IsActiveDashboardOverlay(handle))return key;}
|
||||
return {};
|
||||
}
|
||||
static void observe_dashboard(const vr::VREvent_t& event){
|
||||
if(event.eventType!=vr::VREvent_OverlayCreated&&event.eventType!=vr::VREvent_OverlayShown&&event.eventType!=vr::VREvent_DashboardOverlayCreated)return;
|
||||
char key[1024]{};vr::EVROverlayError error{};overlays->GetOverlayKey(event.data.overlay.overlayHandle,key,sizeof(key),&error);
|
||||
if(!error&&key[0]&&dashboard_keys.size()<512)dashboard_keys.insert(key);
|
||||
}
|
||||
static void finish_launcher_close(){
|
||||
launcher_fade.reset();
|
||||
if(!views.count("launcher"))return;auto& v=*views.at("launcher");
|
||||
overlays->SetOverlayAlpha(v.overlay,0.f);
|
||||
// Do not steal focus if Steam or the user already selected another window.
|
||||
if(!overlays->IsActiveDashboardOverlay(v.overlay)){overlays->HideOverlay(v.overlay);return;}
|
||||
if(launcher_restore_previous&&!launcher_previous_dashboard.empty()){
|
||||
vr::VROverlayHandle_t previous=0;if(!overlays->FindOverlay(launcher_previous_dashboard.c_str(),&previous)){
|
||||
overlays->ShowDashboard(launcher_previous_dashboard.c_str());menu_open=launcher_previous_menu;return;
|
||||
}
|
||||
}
|
||||
// Unknown/destroyed previous windows are left to Steam's window selection.
|
||||
overlays->HideOverlay(v.overlay);
|
||||
if(!launcher_restore_previous)overlays->ShowDashboard("valve.steam.gamepadui.main");
|
||||
if(launcher_restore_previous)menu_open=launcher_previous_menu;
|
||||
}
|
||||
static void hide_launcher(bool restore=true,bool immediate=false){
|
||||
if(keyboard_view=="launcher")close_keyboard();
|
||||
const bool was_open=launcher_open;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
|
||||
if(launcher_calibrating&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"location.hash='';window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
|
||||
const bool was_open=launcher_open;if(was_open)launcher_restore_previous=restore;launcher_calibrating=false;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
|
||||
if(immediate){launcher_close_at={};finish_launcher_close();return;}
|
||||
if(was_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){
|
||||
auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);
|
||||
execute(frame,"window.dispatchEvent(new Event('framely.launcher.closing'))");frame->base.release(&frame->base);
|
||||
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:480);
|
||||
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:240);
|
||||
}
|
||||
}
|
||||
static void launcher_back(){
|
||||
@@ -291,18 +380,20 @@ static bool launcher_accepts(const vr::VREvent_t& e){
|
||||
}
|
||||
static void toggle_entry(bool launcher){
|
||||
if(launcher){
|
||||
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
|
||||
if(launcher_open){hide_launcher();return;}
|
||||
if(!launcher_close_at.time_since_epoch().count()||!views.count("launcher")||!overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){launcher_previous_dashboard=selected_dashboard();launcher_previous_menu=menu_open;}launcher_restore_previous=true;
|
||||
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
|
||||
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1440,800);
|
||||
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
|
||||
launcher_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
// Steam must see a visible dashboard surface before activating it. Zero
|
||||
// alpha can prevent activation, so never wait for selection to start a fade.
|
||||
overlays->SetOverlayAlpha(view.overlay,1.f);
|
||||
overlays->ShowDashboard("launcher");
|
||||
++launcher_session;launcher_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
|
||||
// Wake CEF before activation. A small nonzero alpha keeps Steam activation
|
||||
// working without exposing the previous session's full-opacity texture.
|
||||
launcher_fade.open(std::chrono::steady_clock::now());
|
||||
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
|
||||
}else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
|
||||
view.host->was_hidden(view.host,0);view.host->invalidate(view.host,PET_VIEW);
|
||||
overlays->SetOverlayAlpha(view.overlay,.02f);overlays->ShowOverlay(view.overlay);
|
||||
overlays->ShowDashboard("launcher");
|
||||
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.framelyLauncherSession="+std::to_string(launcher_session)+";window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
|
||||
}else{hide_launcher(false);menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
|
||||
}
|
||||
static void setup_entry_actions(){
|
||||
char exe[4096]{};auto n=readlink("/proc/self/exe",exe,sizeof(exe)-1);if(n<=0)return;
|
||||
@@ -312,6 +403,7 @@ static void setup_entry_actions(){
|
||||
auto error=input->SetActionManifestPath(std::filesystem::absolute(manifest).lexically_normal().c_str());if(error!=vr::VRInputError_None){std::cerr<<"Action manifest failed: "<<error<<std::endl;return;}
|
||||
entry_actions_ready=input->GetActionSetHandle("/actions/entry",&entry_set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim_action)==vr::VRInputError_None;
|
||||
input->GetActionHandle("/actions/entry/in/close",&close_action);input->GetActionHandle("/actions/entry/in/navigate",&navigate_action);
|
||||
input->GetActionSetHandle("/actions/gaze",&gaze_set);input->GetActionHandle("/actions/gaze/in/eye",&gaze_action);input->GetActionHandle("/actions/entry/in/confirm",&confirm_action);input->GetActionHandle("/actions/entry/out/gazeHaptic",&gaze_haptic_action);
|
||||
input->GetInputSourceHandle("/user/hand/left",&hands[0]);input->GetInputSourceHandle("/user/hand/right",&hands[1]);
|
||||
std::cout<<"Framely independent trigger bindings "<<(entry_actions_ready?"ready":"unavailable")<<std::endl;
|
||||
}
|
||||
@@ -327,10 +419,68 @@ static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t dev
|
||||
auto delta=page_scroll_stick.update(x,y,target->stick_scrolling,now);
|
||||
if(delta.x||delta.y){auto mouse=target->pointer;mouse.modifiers=0;target->host->send_mouse_wheel_event(target->host,&mouse,delta.x,delta.y);}
|
||||
}
|
||||
static bool launcher_gaze_allowed(){
|
||||
if(!launcher_open||keyboard_open||!views.count("launcher")||launcher_dock_interaction())return false;
|
||||
auto& v=*views.at("launcher");return !v.closing&&overlays->IsActiveDashboardOverlay(v.overlay)&&overlays->IsOverlayVisible(v.overlay)&&(launcher_calibrating||(!overlays->IsHoverTargetOverlay(v.overlay)&&!v.launcher_pointer.active));
|
||||
}
|
||||
static void launcher_gaze_event(const json& sample){
|
||||
if(!views.count("launcher"))return;auto& v=*views.at("launcher");if(v.closing||!v.browser)return;
|
||||
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:"+sample.dump()+"}))");frame->base.release(&frame->base);
|
||||
}
|
||||
static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connected)[2],const bool (&buttons)[2],std::chrono::steady_clock::time_point now){
|
||||
allowed=allowed&&launcher_gaze_allowed();
|
||||
vr::VREyeTrackingData_t eye{};vr::VROverlayIntersectionParams_t ray{};vr::VROverlayIntersectionResults_t hit{};
|
||||
auto sampled_at=std::chrono::steady_clock::now();
|
||||
bool tracked=allowed&&gaze_action&&input->GetEyeTrackingDataRelativeToNow(gaze_action,vr::TrackingUniverseStanding,0,&eye,sizeof(eye))==vr::VRInputError_None&&launcher_gaze_ray(eye,ray);
|
||||
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
|
||||
// Use the same nominal time for gaze and head pose, rather than pairing gaze
|
||||
// with a later/predicted rendering pose while the user turns their head.
|
||||
if(tracked)vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,std::chrono::duration<float>(sampled_at-std::chrono::steady_clock::now()).count(),poses,vr::k_unMaxTrackedDeviceCount);
|
||||
GazeAngles raw{},expected{};tracked=tracked&&poses[0].bPoseIsValid&&gaze_head_angles(poses[0].mDeviceToAbsoluteTracking,ray.vDirection,raw);
|
||||
bool target_valid=false;
|
||||
if(tracked&&launcher_calibrating&&!gaze_target_id.empty()){
|
||||
auto& view=*views.at("launcher");auto& head=poses[0].mDeviceToAbsoluteTracking;
|
||||
auto sample=[&](GazeAngles angles,float& u,float& v){auto candidate=gaze_world_ray(head,ray.vSource,angles);vr::VROverlayIntersectionResults_t result{};bool valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);u=result.vUVs.v[0];v=result.vUVs.v[1];return valid&&std::isfinite(u)&&std::isfinite(v);};
|
||||
if(gaze_target_cached&&gaze_angles_to_point(head,ray.vSource,gaze_target_world,expected)){float u,v;target_valid=sample(expected,u,v)&&std::hypot(u-gaze_target_u,v-gaze_target_v)<.001f;}
|
||||
if(!target_valid){
|
||||
vr::HmdMatrix34_t seed_pose{};GazeAngles seed{};
|
||||
if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{gaze_target_u*view.width,gaze_target_v*view.height}},&seed_pose)){
|
||||
vr::HmdVector3_t seed_point{{seed_pose.m[0][3],seed_pose.m[1][3],seed_pose.m[2][3]}};
|
||||
bool solved=gaze_angles_to_point(head,ray.vSource,seed_point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);
|
||||
if(!solved){vr::HmdMatrix34_t center{};if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{view.width*.5f,view.height*.5f}},¢er)){vr::HmdVector3_t point{{center.m[0][3],center.m[1][3],center.m[2][3]}};solved=gaze_angles_to_point(head,ray.vSource,point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);}}
|
||||
if(solved){
|
||||
auto candidate=gaze_world_ray(head,ray.vSource,expected);vr::VROverlayIntersectionResults_t result{};
|
||||
target_valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);if(target_valid){gaze_target_world=result.vPoint;gaze_target_cached=true;}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(tracked){double matrix[6];if(gaze_matrix(launcher_calibrating?gaze_preview:gaze_correction,matrix))ray=gaze_world_ray(poses[0].mDeviceToAbsoluteTracking,ray.vSource,gaze_correct(raw,matrix));}
|
||||
bool valid=tracked&&overlays->ComputeOverlayIntersection(views.at("launcher")->overlay,&ray,&hit)&&std::isfinite(hit.vUVs.v[0])&&std::isfinite(hit.vUVs.v[1])&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
|
||||
// Looking away during a press keeps the locked target. Tracking loss cancels.
|
||||
auto edge=gaze_press.update(!launcher_calibrating&&tracked&&(valid||gaze_press.held),connected,buttons);
|
||||
if(edge==LauncherGazePress::Down)gaze_feedback_hand=gaze_press.owner;
|
||||
if(edge==LauncherGazePress::Cancel||(!tracked&&gaze_active)){launcher_gaze_event(!allowed||edge==LauncherGazePress::Cancel?json{{"valid",false},{"phase","cancel"}}:json{{"valid",false}});gaze_active=false;return;}
|
||||
if(!tracked)return;
|
||||
if(edge==LauncherGazePress::Down||edge==LauncherGazePress::Up){launcher_gaze_event({{"valid",true},{"phase",edge==LauncherGazePress::Down?"down":"up"}});gaze_sent=now;}
|
||||
else if(now-gaze_sent>=std::chrono::milliseconds(16)){
|
||||
auto& v=*views.at("launcher");json event=valid?json{{"valid",true},{"x",hit.vUVs.v[0]*v.width},{"y",(1-hit.vUVs.v[1])*v.height}}:json{{"valid",bool(gaze_press.held)}};
|
||||
if(launcher_calibrating){event["valid"]=tracked&&target_valid;event["targetId"]=gaze_target_id;event["targetUnavailable"]=tracked&&!target_valid;event["rawAngles"]={raw.yaw,raw.pitch};event["targetAngles"]={expected.yaw,expected.pitch};event["headPose"]=json::array();for(int r=0;r<3;r++)for(int c=0;c<4;c++)event["headPose"].push_back(poses[0].mDeviceToAbsoluteTracking.m[r][c]);}
|
||||
launcher_gaze_event(event);gaze_sent=now;
|
||||
}
|
||||
gaze_active=true;
|
||||
}
|
||||
static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t set{};set.ulActionSet=entry_set;
|
||||
if(input->UpdateActionState(&set,sizeof(set),1)!=vr::VRInputError_None)return;
|
||||
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
|
||||
// Only the eye action participates in overlay priority. Controller bindings
|
||||
// keep their existing priority so Steam laser and Dock controls are preserved.
|
||||
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
|
||||
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
|
||||
auto now=std::chrono::steady_clock::now();
|
||||
// Dock ownership must be checked before reading confirm edges. Gaze focus
|
||||
// alone does not own a trigger aimed at the entry, even before mouse-down
|
||||
// arrives. Disabling gaze disarms it until all confirm inputs are released.
|
||||
bool gaze_allowed=launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button)));bool gaze_connected[2]{},gaze_buttons[2]{};
|
||||
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
|
||||
for(int hand=0;hand<2;hand++){
|
||||
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
|
||||
@@ -345,6 +495,14 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
}
|
||||
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,¶ms,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
|
||||
|
||||
if(hit)gaze_allowed=false;
|
||||
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
|
||||
if(gaze_connected[hand]){vr::InputDigitalActionData_t confirm{};bool active=input->GetDigitalActionData(confirm_action,&confirm,sizeof(confirm),hands[hand])==vr::VRInputError_None&&confirm.bActive;gaze_buttons[hand]=active&&confirm.bState;
|
||||
if(!active){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
|
||||
if(hand==1)gaze_buttons[hand]=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_a",hands[hand],&mode,&state)&&(state.uProperties&vr::VRComponentProperty_IsPressed);
|
||||
else{vr::VRControllerState_t controller{};gaze_buttons[hand]=vr_system->GetControllerState(device,&controller,sizeof(controller))&&(controller.ulButtonPressed&(vr::ButtonMaskFromId(vr::k_EButton_DPad_Down)|vr::ButtonMaskFromId(vr::k_EButton_A)));}
|
||||
}
|
||||
}
|
||||
for(int kind=0;kind<2;kind++){vr::InputDigitalActionData_t data{};bool active=input->GetDigitalActionData(kind?upper_action:primary_action,&data,sizeof(data),hands[hand])==vr::VRInputError_None&&data.bActive;
|
||||
bool down=data.bState;if(kind==0&&active)entry_analog[hand].held=down;
|
||||
// Dashboard focus suppresses action data. Frame's render-model components
|
||||
@@ -368,9 +526,10 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
}
|
||||
}
|
||||
if(kind==0&&!active)entry_analog[hand].held=false;
|
||||
if(kind==0&&active)gaze_buttons[hand]|=down;
|
||||
bool was_held=entry_buttons[hand][kind].held;
|
||||
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
|
||||
else if(kind==0&&active&&down&&!was_held&&launcher_open&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
entry_buttons[hand][kind].update(active,down,hit,now);
|
||||
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
}
|
||||
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
|
||||
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
|
||||
@@ -381,14 +540,16 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
// Pick a single controller and a single axis; never emit two directions.
|
||||
if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;stick_device=device;}
|
||||
}
|
||||
launcher_gaze(input,gaze_allowed,gaze_connected,gaze_buttons,now);
|
||||
if(launcher_open){const bool blocked=keyboard_open||launcher_dock_interaction();int direction=launcher_stick.update(blocked?0:stick_x,blocked?0:stick_y,!blocked&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
|
||||
else{launcher_stick={};launcher_close_button={};}
|
||||
page_scroll(stick_x,stick_y,stick_active,stick_device,now);
|
||||
if(gaze_allowed&&gaze_active)launcher_outside_at={};
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",true);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely nLine truncated
|
||||
}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
|
||||
int main(int argc,char** argv){
|
||||
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
|
||||
@@ -410,7 +571,9 @@ int main(int argc,char** argv){
|
||||
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
|
||||
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
|
||||
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){
|
||||
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;if(ui_sounds)ui_audio.play();int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(ui_haptics&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
|
||||
return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
|
||||
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
|
||||
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
|
||||
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
|
||||
@@ -422,11 +585,13 @@ int main(int argc,char** argv){
|
||||
auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:"+json({{"id",v->file_dialog_id},{"multiple",v->file_multiple},{"directory",v->file_directory},{"accept",accept},{"defaultPath",default_path?text(default_path):std::string{}}}).dump()+"}))");frame->base.release(&frame->base);return 1;
|
||||
};
|
||||
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}keyboard_event(global);}vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",true)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
|
||||
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
|
||||
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
|
||||
@@ -442,30 +607,52 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))overlays->ShowDashboard("valve.steam.gamepadui.main");}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
|
||||
// The runtime owns dashboard placement, curvature, visibility and switching.
|
||||
// Never move the launcher into standing space or hide another app's overlay.
|
||||
if(launcher_open)launcher_dock_interaction();
|
||||
if(views.count("launcher")){
|
||||
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
|
||||
if(launcher_open&&selected)launcher_selected=true;
|
||||
if(launcher_open&&selected&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(views.at("launcher")->overlay)&&views.at("launcher")->texture_ready[views.at("launcher")->texture_slot])launcher_fade.ready(now);
|
||||
if(launcher_open||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
|
||||
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
|
||||
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(5))){hide_launcher(false,true);}
|
||||
}
|
||||
// Toggle once for a matched left click; never activate on a stale release.
|
||||
auto activate_entry=[&](EntryHold::Action action){
|
||||
if(action==EntryHold::Action::Idle)return;
|
||||
if(ui_sounds)ui_audio.play(true);
|
||||
toggle_entry(action==EntryHold::Action::Launcher);
|
||||
if(menu_open&&views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);
|
||||
};
|
||||
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
|
||||
laser_cursor(button,event);
|
||||
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
|
||||
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
|
||||
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
|
||||
if(!entry_actions_ready&&menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
|
||||
toggle_entry(launcher_primary.load());if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
|
||||
std::cout<<"Menu "<<(menu_open?"opened":"closed")<<std::endl;
|
||||
// Cancel any existing gaze gesture before toggling views. This also
|
||||
// covers a complete down/up pair drained in one frame and unknown devices.
|
||||
if(event.eventType==vr::VREvent_MouseButtonDown&&anchor_ok&&entry_pointer.inside){
|
||||
auto edge=gaze_press.cancel();if(gaze_active||edge==LauncherGazePress::Cancel)launcher_gaze_event({{"valid",false},{"phase","cancel"}});gaze_active=false;
|
||||
}
|
||||
const auto received=std::chrono::steady_clock::now();const bool was_pressed=entry_hold.pressed;const auto previous_started=entry_hold.started;
|
||||
activate_entry(entry_hold.event(event.eventType==vr::VREvent_MouseButtonDown,event.trackedDeviceIndex,event.data.mouse.button,anchor_ok&&(event.trackedDeviceIndex==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(event.trackedDeviceIndex)),entry_pointer.inside,entry_event_time(received,event.eventAgeSeconds),received));
|
||||
if(entry_hold.pressed&&(!was_pressed||entry_hold.started!=previous_started)){
|
||||
entry_source_buttons=0;vr::VRControllerState_t state{};
|
||||
if(entry_hold.device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))){
|
||||
const auto previous=entry_previous_buttons.find(entry_hold.device);
|
||||
if(previous!=entry_previous_buttons.end())entry_source_buttons=state.ulButtonPressed&~previous->second;
|
||||
// An unambiguous single button is safe even if its down event arrived late.
|
||||
if(!entry_source_buttons&&state.ulButtonPressed&&(state.ulButtonPressed&(state.ulButtonPressed-1))==0)entry_source_buttons=state.ulButtonPressed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!anchor_ok)entry_pointer.leave();
|
||||
const bool entry_connected=entry_hold.device==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(entry_hold.device);
|
||||
const bool entry_inside=entry_pointer.inside&&entry_pointer.device==entry_hold.device;
|
||||
if(entry_hold.pressed&&entry_source_buttons){vr::VRControllerState_t state{};if(vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))&&!(state.ulButtonPressed&entry_source_buttons))activate_entry(entry_hold.released(anchor_ok&&entry_connected,entry_inside));}
|
||||
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
|
||||
for(auto&[key,v]:views)v->stick_scrolling=false;
|
||||
entry_actions(button,anchor_ok);
|
||||
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
|
||||
@@ -534,11 +721,14 @@ int main(int argc,char** argv){
|
||||
close_keyboard(false);
|
||||
}
|
||||
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
|
||||
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
|
||||
bool hovered=anchor_ok&&(entry_hold.pressed||overlays->IsHoverTargetOverlay(button));if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();const float ring=entry_hold.progress(std::chrono::steady_clock::now());const int progress=ring>0?std::max(1,int(std::lround(ring*120))):0;if(icon_progress!=progress||icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){pixels=dock_icon(hovered,menu_open||launcher_open,progress/120.f);if(icon_texture.submit(button,pixels)){icon_progress=progress;icon_badge=badge;icon_active=menu_open||launcher_open;}}icon_hover=hovered;
|
||||
// Remember source state only after draining events, to identify the actual
|
||||
// newly pressed button rather than unrelated buttons held on the controller.
|
||||
for(auto device:{entry_pointer.device,entry_hold.device})if(device!=vr::k_unTrackedDeviceIndexInvalid){vr::VRControllerState_t state{};if(vr_system->GetControllerState(device,&state,sizeof(state)))entry_previous_buttons[device]=state.ulButtonPressed;else entry_previous_buttons.erase(device);}
|
||||
auto sync=overlays->WaitFrameSync(8);
|
||||
// A timeout already waited: never append another sleep to it. Bound retries
|
||||
// only for immediate API failures, so a broken sync call cannot busy-spin.
|
||||
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
|
||||
}
|
||||
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
|
||||
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
{
|
||||
"name": "/actions/entry",
|
||||
"usage": "leftright"
|
||||
},
|
||||
{
|
||||
"name": "/actions/gaze",
|
||||
"usage": "single"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -25,12 +29,33 @@
|
||||
{
|
||||
"name": "/actions/entry/in/navigate",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/gaze/in/eye",
|
||||
"type": "eyetracking",
|
||||
"requirement": "optional"
|
||||
},
|
||||
{
|
||||
"name": "/actions/entry/in/confirm",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/entry/out/gazeHaptic",
|
||||
"type": "vibration"
|
||||
}
|
||||
],
|
||||
"default_bindings": [
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"binding_url": "frame-controller.json"
|
||||
},
|
||||
{
|
||||
"controller_type": "generic_hmd",
|
||||
"binding_url": "generic_hmd.json"
|
||||
},
|
||||
{
|
||||
"controller_type": "frame_hmd",
|
||||
"binding_url": "frame_hmd.json"
|
||||
}
|
||||
],
|
||||
"localization": [
|
||||
@@ -41,7 +66,11 @@
|
||||
"/actions/entry/in/upper": "Upper trigger",
|
||||
"/actions/entry/in/aim": "Pointer",
|
||||
"/actions/entry/in/close": "Close launcher (B)",
|
||||
"/actions/entry/in/navigate": "Launcher navigation"
|
||||
"/actions/entry/in/navigate": "Launcher navigation",
|
||||
"/actions/entry/in/confirm": "Confirm gaze target (left lower button / A)",
|
||||
"/actions/entry/out/gazeHaptic": "Gaze focus feedback",
|
||||
"/actions/gaze": "Framely eye tracking",
|
||||
"/actions/gaze/in/eye": "Eye gaze"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -92,6 +92,40 @@
|
||||
"output": "/actions/entry/in/navigate"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_down",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/entry/in/confirm"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"haptic_amplitude": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/entry/in/confirm"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"haptic_amplitude": "0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"haptics": [
|
||||
{
|
||||
"path": "/user/hand/left/output/haptic",
|
||||
"output": "/actions/entry/out/gazeHaptic"
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/output/haptic",
|
||||
"output": "/actions/entry/out/gazeHaptic"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"controller_type": "frame_hmd",
|
||||
"name": "Framely eye tracking",
|
||||
"bindings": {
|
||||
"/actions/gaze": {
|
||||
"eyetracking": [
|
||||
{
|
||||
"path": "/user/head/eyetracking",
|
||||
"output": "/actions/gaze/in/eye"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Framely's isolated virtual Android gamepad; Xbox-compatible scan codes.
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 317 BUTTON_THUMBL
|
||||
key 318 BUTTON_THUMBR
|
||||
axis 0x00 X flat 1024
|
||||
axis 0x01 Y flat 1024
|
||||
axis 0x03 Z flat 1024
|
||||
axis 0x04 RZ flat 1024
|
||||
axis 0x02 LTRIGGER
|
||||
axis 0x05 RTRIGGER
|
||||
axis 0x10 HAT_X
|
||||
axis 0x11 HAT_Y
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"action_sets": [
|
||||
{
|
||||
"name": "/actions/framely_gamepad",
|
||||
"usage": "leftright"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_up",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_down",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_left",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_dpad_right",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_bumper",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_thumbstick",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_trigger",
|
||||
"type": "vector1"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/left_stickclick",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_a",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_b",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_x",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_y",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_bumper",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_thumbstick",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_trigger",
|
||||
"type": "vector1"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/in/right_stickclick",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/out/left_haptic",
|
||||
"type": "vibration"
|
||||
},
|
||||
{
|
||||
"name": "/actions/framely_gamepad/out/right_haptic",
|
||||
"type": "vibration"
|
||||
}
|
||||
],
|
||||
"default_bindings": [
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"binding_url": "bindings.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"name": "Framely Android gamepad",
|
||||
"bindings": {
|
||||
"/actions/framely_gamepad": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_up",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_up"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_down",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_down"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_left",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_left"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_right",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_dpad_right"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/bumper",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_bumper"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/view",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_menu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/framely_gamepad/in/left_thumbstick"
|
||||
},
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/left_stickclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/trigger",
|
||||
"mode": "trigger",
|
||||
"inputs": {
|
||||
"pull": {
|
||||
"output": "/actions/framely_gamepad/in/left_trigger"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_a"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/b",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_b"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/x",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_x"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/y",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_y"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/bumper",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_bumper"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/menu",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_menu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/framely_gamepad/in/right_thumbstick"
|
||||
},
|
||||
"click": {
|
||||
"output": "/actions/framely_gamepad/in/right_stickclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/trigger",
|
||||
"mode": "trigger",
|
||||
"inputs": {
|
||||
"pull": {
|
||||
"output": "/actions/framely_gamepad/in/right_trigger"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"haptics": [
|
||||
{
|
||||
"path": "/user/hand/left/output/haptic",
|
||||
"output": "/actions/framely_gamepad/out/left_haptic"
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/output/haptic",
|
||||
"output": "/actions/framely_gamepad/out/right_haptic"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"controller_type": "generic_hmd",
|
||||
"name": "Framely eye tracking",
|
||||
"bindings": {
|
||||
"/actions/gaze": {
|
||||
"eyetracking": [
|
||||
{
|
||||
"path": "/user/head/eyetracking",
|
||||
"output": "/actions/gaze/in/eye"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
// Compositor alpha fades even when a cached CEF texture has not repainted yet.
|
||||
// Only compositor alpha animates; CEF can reuse its cached texture throughout.
|
||||
struct LauncherFade {
|
||||
bool reduced=false;float from=1,to=1;int duration=0;
|
||||
bool reduced=false,waiting=false;float from=0,to=0;int duration=0;
|
||||
std::chrono::steady_clock::time_point started{};
|
||||
float value(std::chrono::steady_clock::time_point now)const{
|
||||
if(waiting)return 0;
|
||||
if(reduced||!duration)return to;
|
||||
float t=std::clamp(std::chrono::duration<float,std::milli>(now-started).count()/duration,0.f,1.f);
|
||||
float eased=t*t*(3-2*t);return from+(to-from)*eased;
|
||||
float eased=to>from?1-(1-t)*(1-t)*(1-t):t*t*(3-2*t);
|
||||
return from+(to-from)*eased;
|
||||
}
|
||||
void open(std::chrono::steady_clock::time_point now){from=0;to=1;duration=580;started=now;}
|
||||
void close(std::chrono::steady_clock::time_point now){from=value(now);to=0;duration=440;started=now;}
|
||||
void open(std::chrono::steady_clock::time_point now){from=0;to=1;duration=320;started=now;waiting=true;}
|
||||
void ready(std::chrono::steady_clock::time_point now){if(waiting){waiting=false;started=now;}}
|
||||
void close(std::chrono::steady_clock::time_point now){from=value(now);waiting=false;to=0;duration=240;started=now;}
|
||||
void reset(){waiting=false;from=to=0;duration=0;}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include "vendor/openvr/openvr.h"
|
||||
inline bool launcher_gaze_ray(const vr::VREyeTrackingData_t& eye,vr::VROverlayIntersectionParams_t& ray){
|
||||
if(!eye.bActive||!eye.bValid||!eye.bTracked)return false;
|
||||
double length=0;for(int i=0;i<3;i++){if(!std::isfinite(eye.vGazeOrigin.v[i])||!std::isfinite(eye.vGazeTarget.v[i]))return false;ray.vSource.v[i]=eye.vGazeOrigin.v[i];ray.vDirection.v[i]=eye.vGazeTarget.v[i]-eye.vGazeOrigin.v[i];length+=double(ray.vDirection.v[i])*ray.vDirection.v[i];}
|
||||
if(!std::isfinite(length)||length<1e-8)return false;
|
||||
for(float& v:ray.vDirection.v)v/=float(std::sqrt(length));ray.eOrigin=vr::TrackingUniverseStanding;return true;
|
||||
}
|
||||
// Release-to-arm prevents the entry press, overlapping buttons, disconnects,
|
||||
// or a mode switch while held from becoming a fresh gaze click.
|
||||
struct LauncherGazePress {
|
||||
bool ready=false,held=false;int owner=-1;
|
||||
enum Edge {None,Down,Up,Cancel};
|
||||
Edge cancel(){auto edge=held?Cancel:None;ready=false;held=false;owner=-1;return edge;}
|
||||
Edge update(bool enabled,const bool (&connected)[2],const bool (&buttons)[2]){
|
||||
if(!enabled|| (held&&(owner<0||!connected[owner]))){return cancel();}
|
||||
if(held){if(buttons[owner])return None;held=false;owner=-1;ready=!buttons[0]&&!buttons[1];return Up;}
|
||||
if(!buttons[0]&&!buttons[1]){ready=true;return None;}
|
||||
if(!ready)return None;ready=false;for(int hand=0;hand<2;hand++)if(connected[hand]&&buttons[hand]){owner=hand;held=true;return Down;}return None;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Only the native renderer and its CEF children participate. Android, terminal
|
||||
// shells and desktop applications belong to the session agent, not this tree.
|
||||
class LauncherPriority {
|
||||
struct Task { unsigned long long born; int nice; };
|
||||
struct Process { int parent; unsigned long long born; };
|
||||
std::map<int,Task> saved;
|
||||
bool active=false,warned=false;
|
||||
int baseline=getpriority(PRIO_PROCESS,0);
|
||||
std::chrono::steady_clock::time_point scanned{};
|
||||
static bool stat(int id,Process& result){
|
||||
std::ifstream f("/proc/"+std::to_string(id)+"/stat");std::string line;std::getline(f,line);
|
||||
auto end=line.rfind(')');if(end==std::string::npos)return false;
|
||||
std::istringstream s(line.substr(end+2));std::string value;
|
||||
for(int field=3;field<=22;field++){if(!(s>>value))return false;try{if(field==4)result.parent=std::stoi(value);if(field==22)result.born=std::stoull(value);}catch(...){return false;}}
|
||||
return true;
|
||||
}
|
||||
public:
|
||||
static int target(int original){return std::min(original,-5);}
|
||||
void update(bool enabled){
|
||||
auto now=std::chrono::steady_clock::now();if(enabled==active&&(!enabled||now-scanned<std::chrono::seconds(1)))return;
|
||||
const bool continuing=active;
|
||||
// Include threads born since the last scan before restoring their inherited priority.
|
||||
if(!enabled&&active){scanned={};update(true);}
|
||||
active=enabled;scanned=now;
|
||||
if(!enabled){for(auto [id,task]:saved){Process p{};if(stat(id,p)&&p.born==task.born)setpriority(PRIO_PROCESS,id,task.nice);}saved.clear();return;}
|
||||
try {
|
||||
std::error_code ec;auto exe=std::filesystem::read_symlink("/proc/self/exe",ec);if(ec)return;
|
||||
std::map<int,Process> processes;
|
||||
for(auto& entry:std::filesystem::directory_iterator("/proc",ec)){
|
||||
const auto name=entry.path().filename().string();if(name.empty()||name.find_first_not_of("0123456789")!=std::string::npos)continue;
|
||||
struct ::stat info{};if(::stat(entry.path().c_str(),&info)||info.st_uid!=geteuid())continue;
|
||||
if(std::filesystem::read_symlink(entry.path()/"exe",ec)!=exe){ec.clear();continue;}
|
||||
Process p{};int id=std::stoi(name);if(stat(id,p))processes[id]=p;
|
||||
}
|
||||
for(auto [pid,process]:processes){
|
||||
int parent=pid;for(int i=0;i<32&&parent!=getpid();i++){auto p=processes.find(parent);if(p==processes.end()){parent=0;break;}parent=p->second.parent;}
|
||||
if(parent!=getpid())continue;
|
||||
for(auto& entry:std::filesystem::directory_iterator("/proc/"+std::to_string(pid)+"/task",ec)){
|
||||
int id;try{id=std::stoi(entry.path().filename().string());}catch(...){continue;}
|
||||
Process p{};if(!stat(id,p))continue;
|
||||
auto old=saved.find(id);if(old!=saved.end()&&old->second.born!=p.born){saved.erase(old);old=saved.end();}
|
||||
if(old==saved.end()){errno=0;int nice=getpriority(PRIO_PROCESS,id);if(errno)continue;
|
||||
// CEF threads created during a boost inherit it; restore the baseline.
|
||||
if(continuing&&nice==target(baseline))nice=baseline;
|
||||
old=saved.emplace(id,Task{p.born,nice}).first;
|
||||
}
|
||||
if(setpriority(PRIO_PROCESS,id,target(old->second.nice))&&!warned&&errno!=ESRCH){warned=true;std::cerr<<"Launcher CPU priority unavailable (requires service LimitNICE=25)\n";}
|
||||
}
|
||||
}
|
||||
for(auto it=saved.begin();it!=saved.end();){Process p{};if(!stat(it->first,p)||p.born!=it->second.born)it=saved.erase(it);else++it;}
|
||||
} catch(const std::filesystem::filesystem_error&) { /* Child may exit during enumeration; retry next tick. */ }
|
||||
}
|
||||
~LauncherPriority(){update(false);}
|
||||
};
|
||||
@@ -19,3 +19,7 @@ inline bool allow_plugin_navigation(const std::string& url, const std::string& o
|
||||
}
|
||||
return port > 0;
|
||||
}
|
||||
|
||||
inline bool manager_navigation_page(const std::string& page) {
|
||||
return page=="installed"||page=="catalog"||page=="sources"||page=="notification-settings"||page=="settings"||page=="launcher-settings"||page=="updates"||page=="about"||page=="terminal"||page=="files"||page=="apk"||page=="apk-containers"||page=="apk-cleanup"||page=="apk-settings";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <dlfcn.h>
|
||||
#include <string>
|
||||
#include <iostream>
|
||||
|
||||
// Load Steam's installed SDL/audio assets; no Steam sounds are redistributed.
|
||||
// Only the native UI thread owns these streams. Playback queues cached PCM and
|
||||
// never spawns a process or reads files on a hover/click.
|
||||
class SteamUiSounds {
|
||||
struct Spec { int format, channels, freq; };
|
||||
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[2];
|
||||
void* library=nullptr;
|
||||
bool initialized=false;
|
||||
bool (*init)(uint32_t)=nullptr;
|
||||
void (*quit)(uint32_t)=nullptr;
|
||||
bool (*load)(const char*,Spec*,uint8_t**,uint32_t*)=nullptr;
|
||||
void* (*open)(uint32_t,const Spec*,void*,void*)=nullptr;
|
||||
bool (*resume)(void*)=nullptr;
|
||||
bool (*put)(void*,const void*,int)=nullptr;
|
||||
bool (*clear)(void*)=nullptr;
|
||||
void (*destroy)(void*)=nullptr;
|
||||
void (*release)(void*)=nullptr;
|
||||
template<class T> bool bind(T& fn,const char* name){fn=reinterpret_cast<T>(dlsym(library,name));return fn!=nullptr;}
|
||||
public:
|
||||
SteamUiSounds()=default;
|
||||
SteamUiSounds(const SteamUiSounds&)=delete;
|
||||
~SteamUiSounds(){shutdown();}
|
||||
void shutdown(){
|
||||
for(auto& clip:clips){if(clip.stream&&destroy)destroy(clip.stream);if(clip.data&&release)release(clip.data);clip={};}
|
||||
if(initialized&&quit)quit(0x10);initialized=false;
|
||||
if(library)dlclose(library);library=nullptr;
|
||||
}
|
||||
bool prepare(const std::string& root){
|
||||
if(root.empty())return false;
|
||||
for(const auto* name:{"steamrtarm64/libSDL3.so.0","steamrt64/libSDL3.so.0"}){library=dlopen((root+"/"+name).c_str(),RTLD_NOW|RTLD_LOCAL);if(library)break;}
|
||||
if(!library)return false;
|
||||
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
|
||||
if(!init(0x10)){shutdown();return false;}initialized=true;
|
||||
const char* names[]={"deck_ui_navigation.wav","deck_ui_default_activation.wav"};
|
||||
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
|
||||
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
|
||||
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
|
||||
if(!clip.stream||!resume(clip.stream)){shutdown();return false;}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void silence(){if(!initialized)return;for(auto& clip:clips)clear(clip.stream);}
|
||||
void play(bool activation=false){if(!initialized)return;silence();auto& clip=clips[activation?1:0];put(clip.stream,clip.data,int(clip.size));}
|
||||
};
|
||||
Generated
+17
@@ -13,6 +13,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
@@ -760,6 +762,21 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
|
||||
@@ -200,6 +200,8 @@ ExecStart=$root/current/bin/framely session --state $root --assets $root/current
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillMode=control-group
|
||||
# Allow a bounded, reversible UI-only boost; no realtime scheduling.
|
||||
LimitNICE=25
|
||||
TimeoutStopSec=10
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -12,6 +12,6 @@ installKeyboard(params=>request('keyboard',params));
|
||||
|
||||
window.addEventListener('pagehide',()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Plugin page closed'));}pending.clear();});
|
||||
|
||||
installHoverFeedback(()=>{void request('haptic').catch(console.error);});
|
||||
installHoverFeedback(feedback=>{void request('haptic',{feedback}).catch(console.error);});
|
||||
|
||||
installScrollbars();
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
// Shared by the host page and isolated plugin frames.
|
||||
export function installHoverFeedback(pulse:()=>void){
|
||||
export function installHoverFeedback(pulse:(kind:'hover'|'activate')=>void){
|
||||
let hovered:Element|null=null;
|
||||
function interactive(target:EventTarget|null):Element|null{
|
||||
const element=target instanceof Element?target:null;
|
||||
@@ -14,8 +14,9 @@ export function installHoverFeedback(pulse:()=>void){
|
||||
}
|
||||
return pointer;
|
||||
}
|
||||
function enter(e:Event){const next=interactive(e.target);if(next!==hovered){hovered=next;if(next)pulse();}}
|
||||
function enter(e:Event){const next=interactive(e.target);if(next!==hovered){hovered=next;if(next)pulse('hover');}}
|
||||
document.addEventListener('pointerover',enter);
|
||||
document.addEventListener('click',e=>{if(interactive(e.target))pulse('activate');});
|
||||
document.addEventListener('pointerout',e=>{if(interactive(e.relatedTarget)!==hovered)hovered=null;});
|
||||
window.addEventListener('blur',()=>{hovered=null;});
|
||||
}
|
||||
+3403
-166
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
//! Delete only storage and runtime objects attributed to one APK context.
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn absent(error: &std::io::Error) -> bool {
|
||||
error.kind() == std::io::ErrorKind::NotFound
|
||||
}
|
||||
fn directory(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => ensure!(
|
||||
m.is_dir() && m.uid() == unsafe { libc::geteuid() },
|
||||
"Invalid cleanup directory"
|
||||
),
|
||||
Err(e) if absent(&e) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remove_owned(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => {
|
||||
ensure!(
|
||||
m.uid() == unsafe { libc::geteuid() },
|
||||
"Cleanup file belongs to another user"
|
||||
);
|
||||
remove_tree(path)?;
|
||||
}
|
||||
Err(e) if absent(&e) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn runtime(name: &str, baked: &Path, original: bool) -> Result<Option<String>> {
|
||||
ensure!(safe(name), "Invalid container name");
|
||||
let result = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.Id}}|{{ index .Config.Labels \"STEAM_COMPAT_DATA_PATH\" }}|{{json .Mounts}}",
|
||||
&format!("lepton-{name}"),
|
||||
]),
|
||||
Duration::from_secs(10),
|
||||
true,
|
||||
)?;
|
||||
if !result.status.success() {
|
||||
let error = String::from_utf8_lossy(&result.stderr).to_lowercase();
|
||||
ensure!(
|
||||
error.contains("no such") || error.contains("does not exist"),
|
||||
"Cannot verify container ownership"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let text = String::from_utf8(result.stdout)?;
|
||||
let mut fields = text.trim().splitn(3, '|');
|
||||
let id = fields.next().context("Unknown container identity")?;
|
||||
let location = fields
|
||||
.next()
|
||||
.context("Invalid container ownership response")?;
|
||||
let mounts = fields.next().context("Missing container mounts")?;
|
||||
ensure!(!id.is_empty(), "Unknown container identity");
|
||||
if location.is_empty() || location == "<no value>" {
|
||||
// Older direct runtimes have no compatdata label. Verify their actual
|
||||
// Android data mount as well as the unambiguous original name.
|
||||
ensure!(original, "Cannot verify Steam container storage");
|
||||
let mounts: Vec<Value> = serde_json::from_str(mounts)?;
|
||||
ensure!(
|
||||
mounts.iter().any(|m| m["Destination"] == "/data"
|
||||
&& m["Source"]
|
||||
.as_str()
|
||||
.is_some_and(|p| fs::canonicalize(p).is_ok_and(|p| p.starts_with(baked)))),
|
||||
"Container data mount belongs to another location"
|
||||
);
|
||||
} else {
|
||||
ensure!(
|
||||
fs::canonicalize(Path::new(location).join("baked"))? == baked,
|
||||
"Container belongs to another data location"
|
||||
);
|
||||
}
|
||||
Ok(Some(id.to_owned()))
|
||||
}
|
||||
|
||||
pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path) -> Result<()> {
|
||||
let records: Vec<_> = db
|
||||
.records
|
||||
.values()
|
||||
.filter(|r| r.context == c.id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let steam = root(home).join("steam");
|
||||
directory(&steam)?;
|
||||
directory(&steam.join("leases"))?;
|
||||
for rec in &records {
|
||||
let lease = steam.join("leases").join(format!("{}.json", hash(&rec.id)));
|
||||
if let Ok(m) = fs::symlink_metadata(&lease) {
|
||||
if m.is_file() {
|
||||
ensure!(
|
||||
m.len() <= 8192 && m.uid() == unsafe { libc::geteuid() },
|
||||
"Invalid Steam lease file"
|
||||
);
|
||||
let value: Value = serde_json::from_slice(&fs::read(&lease)?)?;
|
||||
ensure!(
|
||||
value["app"] == rec.id,
|
||||
"Steam lease belongs to another application"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let original = if c.id.starts_with("external-") {
|
||||
db.root_contexts
|
||||
.get(&c.baked)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| c.name.clone())
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
let standard = home.join(".local/share/lepton/contexts").join(&c.id);
|
||||
let default_location = !c.id.starts_with("external-")
|
||||
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
|
||||
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
|
||||
let target = if default_location {
|
||||
standard
|
||||
} else {
|
||||
c.baked.clone()
|
||||
};
|
||||
let mut names = BTreeSet::from([original.clone(), c.name.clone()]);
|
||||
names.extend(records.iter().filter_map(native::runtime_name));
|
||||
// Resolve every name before stopping anything. A recycled Steam App ID
|
||||
// pointing at different storage must never be removed.
|
||||
let mut runtimes = Vec::new();
|
||||
for name in names {
|
||||
if let Some(id) = runtime(&name, &c.baked, name == original)? {
|
||||
runtimes.push((name, id));
|
||||
}
|
||||
}
|
||||
for (name, _) in &runtimes {
|
||||
let mut owned = c.clone();
|
||||
owned.name = name.clone();
|
||||
stop(&owned, log)?;
|
||||
}
|
||||
fs::create_dir_all(&steam)?;
|
||||
let ownership = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.mode(0o600)
|
||||
.open(steam.join(format!("context-{}.lock", hash(&c.id))))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Steam launch still owns this container; wait for it to close and retry"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
for rec in &records {
|
||||
steam_shortcuts::unregister(home, rec)?;
|
||||
}
|
||||
ensure!(
|
||||
validate_baked(&c.baked)? == c.baked,
|
||||
"Container data location changed"
|
||||
);
|
||||
for (name, identity) in &runtimes {
|
||||
if let Some(current) = runtime(name, &c.baked, *name == original)? {
|
||||
ensure!(
|
||||
current == *identity && !running(name),
|
||||
"Container changed during deletion"
|
||||
);
|
||||
podman(&["rm", identity], Some(log))?;
|
||||
}
|
||||
}
|
||||
let mut keys = BTreeSet::from([hash(&c.id)]);
|
||||
for rec in &records {
|
||||
let key = hash(&rec.id);
|
||||
keys.insert(key.clone());
|
||||
remove_owned(&root(home).join("apks").join(&key))?;
|
||||
remove_owned(&steam.join(format!("native-{key}.sh")))?;
|
||||
let lease = steam.join("leases").join(format!("{key}.json"));
|
||||
remove_owned(&lease)?;
|
||||
}
|
||||
for entry in fs::read_dir(root(home).join("logs"))? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if entry.path() != log
|
||||
&& name.ends_with(".log")
|
||||
&& keys.iter().any(|key| name.starts_with(&format!("{key}-")))
|
||||
{
|
||||
remove_owned(&entry.path())?;
|
||||
}
|
||||
}
|
||||
remove_tree(&target)?;
|
||||
db.roots.retain(|p| p != &c.baked);
|
||||
db.root_contexts.remove(&c.baked);
|
||||
db.records.retain(|_, r| r.context != c.id);
|
||||
db.owned_contexts.retain(|id| id != &c.id);
|
||||
save(home, db)?;
|
||||
// Keep the context lock inode: unlinking it can split mutual exclusion.
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Session-only lifecycle monitoring; never touches package or application data.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Sample {
|
||||
pub(super) instance: String,
|
||||
pub(super) package: String,
|
||||
window: Option<bool>,
|
||||
pub(super) alive: BTreeSet<String>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Watch {
|
||||
instance: String,
|
||||
package: String,
|
||||
visible: bool,
|
||||
seen: bool,
|
||||
absent: Option<Instant>,
|
||||
closed: Option<Instant>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Reason {
|
||||
WindowClosed,
|
||||
AppExited,
|
||||
}
|
||||
impl Watch {
|
||||
fn observe(
|
||||
&mut self,
|
||||
s: &Sample,
|
||||
now: Instant,
|
||||
automatic: bool,
|
||||
close: bool,
|
||||
) -> Option<Reason> {
|
||||
if self.instance != s.instance || self.package != s.package {
|
||||
*self = Self {
|
||||
instance: s.instance.clone(),
|
||||
package: s.package.clone(),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
if s.window == Some(true) {
|
||||
self.visible = true;
|
||||
self.closed = None;
|
||||
} else if s.window == Some(false) && self.visible {
|
||||
self.closed.get_or_insert(now);
|
||||
} else if s.window.is_none() {
|
||||
self.closed = None;
|
||||
}
|
||||
if s.alive.contains(&s.package) {
|
||||
self.seen = true;
|
||||
self.absent = None;
|
||||
} else if self.seen && automatic {
|
||||
self.absent.get_or_insert(now);
|
||||
} else {
|
||||
self.absent = None;
|
||||
}
|
||||
// A full-display window may represent an old shared context. Protect its other apps.
|
||||
if s.alive.iter().any(|p| p != &s.package) {
|
||||
self.absent = None;
|
||||
return None;
|
||||
}
|
||||
if close
|
||||
&& self
|
||||
.closed
|
||||
.is_some_and(|t| now.saturating_duration_since(t) >= Duration::from_secs(1))
|
||||
{
|
||||
return Some(Reason::WindowClosed);
|
||||
}
|
||||
if automatic
|
||||
&& self
|
||||
.absent
|
||||
.is_some_and(|t| now.saturating_duration_since(t) >= Duration::from_secs(15))
|
||||
{
|
||||
return Some(Reason::AppExited);
|
||||
}
|
||||
None
|
||||
}
|
||||
fn pause(&mut self) {
|
||||
self.absent = None;
|
||||
self.closed = None;
|
||||
}
|
||||
}
|
||||
const PROBE: &str = r#"
|
||||
printf 'FRAMELY_ACTIVE='; getprop lepton.active_app_id
|
||||
printf 'FRAMELY_WINDOW='; getprop waydroid.active_apps
|
||||
printf 'FRAMELY_PROCESSES\n'
|
||||
dumpsys activity processes || exit 1
|
||||
printf '\nFRAMELY_LIFECYCLE_DONE\n'
|
||||
"#;
|
||||
fn short(args: &[&str]) -> Result<String> {
|
||||
let mut cmd = crate::process::tool("podman");
|
||||
cmd.args(args);
|
||||
output(cmd, Duration::from_secs(3), None)
|
||||
}
|
||||
fn instance(c: &Container) -> Result<String> {
|
||||
let text = short(&[
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.Id}}|{{.State.StartedAt}}|{{.State.Running}}",
|
||||
&format!("lepton-{}", c.name),
|
||||
])?;
|
||||
let text = text.trim();
|
||||
ensure!(
|
||||
text.ends_with("|true") && text.split('|').count() == 3,
|
||||
"Container unavailable"
|
||||
);
|
||||
Ok(text.into())
|
||||
}
|
||||
fn packages(c: &Container) -> Result<BTreeSet<String>> {
|
||||
let xml = fs::read_to_string(c.baked.join("data_overlay/system/packages.xml"))?;
|
||||
ensure!(
|
||||
xml.len() <= 16 * 1024 * 1024 && xml.contains("<packages"),
|
||||
"Unknown package database"
|
||||
);
|
||||
let mut out = BTreeSet::new();
|
||||
for tag in xml.split('<').filter(|t| t.starts_with("package ")) {
|
||||
if let (Some(p), Some(path)) = (attr(tag, "name"), attr(tag, "codePath")) {
|
||||
if apk_metadata::valid_package(p) && path.starts_with("/data/") {
|
||||
out.insert(p.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(!out.is_empty(), "No known application packages");
|
||||
Ok(out)
|
||||
}
|
||||
pub(super) fn idle_instance(c: &Container) -> Result<String> {
|
||||
let identity = instance(c)?;
|
||||
let known = packages(c)?;
|
||||
let text = short(&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
"dumpsys activity processes || exit 1; printf '\\nFRAMELY_IDLE_DONE\\n'",
|
||||
"framely-lifecycle-idle",
|
||||
])?;
|
||||
ensure!(
|
||||
text.starts_with("ACTIVITY MANAGER RUNNING PROCESSES")
|
||||
&& text.ends_with("FRAMELY_IDLE_DONE\n"),
|
||||
"Cannot verify whether applications are running"
|
||||
);
|
||||
ensure!(
|
||||
!known.iter().any(|p| gamepad_present(&text, p)),
|
||||
"An application is still running in this container; close it before Steam launch"
|
||||
);
|
||||
ensure!(instance(c)? == identity, "Container changed during launch");
|
||||
Ok(identity)
|
||||
}
|
||||
pub(super) fn sample(c: &Container) -> Result<Sample> {
|
||||
let identity = instance(c)?;
|
||||
let text = short(&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
PROBE,
|
||||
"framely-lifecycle",
|
||||
])?;
|
||||
ensure!(
|
||||
text.ends_with("FRAMELY_LIFECYCLE_DONE\n"),
|
||||
"Incomplete lifecycle response"
|
||||
);
|
||||
let (props, processes) = text
|
||||
.split_once("FRAMELY_PROCESSES\n")
|
||||
.context("Unknown lifecycle response")?;
|
||||
ensure!(
|
||||
processes.starts_with("ACTIVITY MANAGER RUNNING PROCESSES"),
|
||||
"Unknown process response"
|
||||
);
|
||||
let package = props
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("FRAMELY_ACTIVE="))
|
||||
.context("Missing active package")?
|
||||
.trim()
|
||||
.to_owned();
|
||||
ensure!(
|
||||
apk_metadata::valid_package(&package),
|
||||
"Unknown active package"
|
||||
);
|
||||
let window = match props
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("FRAMELY_WINDOW="))
|
||||
{
|
||||
Some("Waydroid") => Some(true),
|
||||
Some("none") => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let packages = packages(c)?;
|
||||
ensure!(
|
||||
packages.contains(&package),
|
||||
"Active package missing from package database"
|
||||
);
|
||||
let alive = packages
|
||||
.into_iter()
|
||||
.filter(|p| gamepad_present(processes, p))
|
||||
.collect();
|
||||
// A restart during the query must not inherit the old absence/close observation.
|
||||
ensure!(
|
||||
instance(c)? == identity,
|
||||
"Container changed while monitoring"
|
||||
);
|
||||
Ok(Sample {
|
||||
instance: identity,
|
||||
package,
|
||||
window,
|
||||
alive,
|
||||
})
|
||||
}
|
||||
fn candidates(home: &Path, db: &Database) -> Vec<Container> {
|
||||
let contexts: BTreeSet<_> = db
|
||||
.records
|
||||
.values()
|
||||
.filter(|r| !r.removed)
|
||||
.map(|r| r.context.clone())
|
||||
.collect();
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
let mut out = Vec::new();
|
||||
for id in contexts {
|
||||
if !safe(&id) || id.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let mut name = id.clone();
|
||||
let mut baked = base.join(&id).join("baked");
|
||||
if id.starts_with("external-") {
|
||||
let Some(path) = db
|
||||
.roots
|
||||
.iter()
|
||||
.find(|p| format!("external-{}", hash(&p.to_string_lossy())) == id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
baked = path.clone();
|
||||
name = db
|
||||
.root_contexts
|
||||
.get(path)
|
||||
.cloned()
|
||||
.or_else(|| path.parent()?.file_name()?.to_str().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if !safe(&name) || name.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let Ok(baked) = validate_baked(&baked) else {
|
||||
continue;
|
||||
};
|
||||
out.push(Container {
|
||||
id,
|
||||
managed: db.owned_contexts.contains(&name),
|
||||
name,
|
||||
baked,
|
||||
running: true,
|
||||
steam: false,
|
||||
});
|
||||
}
|
||||
let mut counts = BTreeMap::new();
|
||||
for c in &out {
|
||||
*counts.entry(c.name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
out.retain(|c| counts[&c.name] == 1);
|
||||
out
|
||||
}
|
||||
struct LaunchMark {
|
||||
instance: String,
|
||||
package: String,
|
||||
flat: bool,
|
||||
}
|
||||
static LAUNCHES: Mutex<BTreeMap<String, LaunchMark>> = Mutex::new(BTreeMap::new());
|
||||
pub(super) fn launched(c: &Container, a: &App) {
|
||||
if let Ok(instance) = instance(c) {
|
||||
LAUNCHES.lock().unwrap().insert(
|
||||
c.name.clone(),
|
||||
LaunchMark {
|
||||
instance,
|
||||
package: a.metadata.package.clone(),
|
||||
flat: a.show_window.unwrap_or(!a.metadata.vr),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Monitor {
|
||||
watches: BTreeMap<String, Watch>,
|
||||
}
|
||||
impl Monitor {
|
||||
pub(super) fn tick(&mut self, home: &Path, now: Instant) -> Result<()> {
|
||||
if MUTATION.try_lock().is_err() {
|
||||
for w in self.watches.values_mut() {
|
||||
w.pause();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let db = load(home)?;
|
||||
if !db.auto_stop_container && !db.stop_container_on_close {
|
||||
self.watches.clear();
|
||||
return Ok(());
|
||||
}
|
||||
let mut cs = candidates(home, &db);
|
||||
let known: BTreeSet<_> = cs.iter().map(|c| c.name.clone()).collect();
|
||||
LAUNCHES.lock().unwrap().retain(|n, _| known.contains(n));
|
||||
let Ok(running) = short(&["ps", "--format", "{{.Names}}|{{.Pid}}"]) else {
|
||||
self.watches.clear();
|
||||
return Ok(());
|
||||
};
|
||||
let running: BTreeSet<_> = running
|
||||
.lines()
|
||||
.filter_map(|row| {
|
||||
let (name, pid) = row.split_once('|')?;
|
||||
runtime_pid_alive(pid)
|
||||
.then(|| name.strip_prefix("lepton-"))
|
||||
.flatten()
|
||||
})
|
||||
.collect();
|
||||
cs.retain(|c| running.contains(c.name.as_str()));
|
||||
let names: BTreeSet<_> = cs.iter().map(|c| c.name.clone()).collect();
|
||||
self.watches.retain(|n, _| names.contains(n));
|
||||
for c in cs {
|
||||
if db
|
||||
.records
|
||||
.values()
|
||||
.any(|r| r.context == c.id && r.pending.is_some())
|
||||
{
|
||||
self.watches.remove(&c.name);
|
||||
continue;
|
||||
}
|
||||
let Ok(s) = sample(&c) else {
|
||||
self.watches.remove(&c.name);
|
||||
continue;
|
||||
};
|
||||
if !db
|
||||
.records
|
||||
.values()
|
||||
.any(|r| r.context == c.id && !r.removed && r.metadata.package == s.package)
|
||||
{
|
||||
self.watches.remove(&c.name);
|
||||
continue;
|
||||
}
|
||||
let w = self.watches.entry(c.name.clone()).or_default();
|
||||
if let Some(mark) = LAUNCHES.lock().unwrap().remove(&c.name) {
|
||||
if mark.instance == s.instance && mark.package == s.package {
|
||||
*w = Watch {
|
||||
instance: mark.instance,
|
||||
package: mark.package,
|
||||
visible: mark.flat,
|
||||
seen: true,
|
||||
..Watch::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
let Some(reason) =
|
||||
w.observe(&s, now, db.auto_stop_container, db.stop_container_on_close)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Match mutation exclusion in operate(), including other Framely CLI processes.
|
||||
let Ok(_guard) = MUTATION.try_lock() else {
|
||||
w.pause();
|
||||
continue;
|
||||
};
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(root(home).join("operation.lock"))?;
|
||||
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||
w.pause();
|
||||
continue;
|
||||
}
|
||||
let latest = load(home)?;
|
||||
if (reason == Reason::AppExited && !latest.auto_stop_container)
|
||||
|| (reason == Reason::WindowClosed && !latest.stop_container_on_close)
|
||||
{
|
||||
w.pause();
|
||||
continue;
|
||||
}
|
||||
if latest
|
||||
.records
|
||||
.values()
|
||||
.any(|r| r.context == c.id && r.pending.is_some())
|
||||
|| !latest
|
||||
.records
|
||||
.values()
|
||||
.any(|r| r.context == c.id && !r.removed && r.metadata.package == s.package)
|
||||
{
|
||||
w.pause();
|
||||
continue;
|
||||
}
|
||||
let Ok(fresh) = sample(&c) else {
|
||||
w.pause();
|
||||
continue;
|
||||
};
|
||||
if fresh.instance != s.instance
|
||||
|| fresh.package != s.package
|
||||
|| fresh.alive.iter().any(|p| p != &s.package)
|
||||
|| (reason == Reason::WindowClosed && fresh.window != Some(false))
|
||||
|| (reason == Reason::AppExited && !fresh.alive.is_empty())
|
||||
{
|
||||
w.pause();
|
||||
continue;
|
||||
}
|
||||
let log = root(home).join("logs").join(format!(
|
||||
"{}-lifecycle-{}.log",
|
||||
hash(&c.id),
|
||||
now_secs()
|
||||
));
|
||||
fs::write(&log, format!("Stopping container: {reason:?}\n"))?;
|
||||
if let Err(e) = stop(&c, &log) {
|
||||
eprintln!("APK lifecycle stop failed for {}: {e:#}", c.name);
|
||||
}
|
||||
self.watches.remove(&c.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn now_secs() -> u64 {
|
||||
super::now()
|
||||
}
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
pub(super) fn start() {
|
||||
STARTED.get_or_init(|| {
|
||||
std::thread::spawn(|| {
|
||||
let mut monitor = Monitor::default();
|
||||
loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
if let Err(e) = monitor.tick(&home, Instant::now()) {
|
||||
eprintln!("APK lifecycle monitor: {e:#}");
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn s(visible: Option<bool>, alive: &[&str]) -> Sample {
|
||||
Sample {
|
||||
instance: "boot1".into(),
|
||||
package: "app.one".into(),
|
||||
window: visible,
|
||||
alive: alive.iter().map(|p| p.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn automatic_exit_requires_confirmed_absence_and_preserves_background_apps() {
|
||||
let mut w = Watch::default();
|
||||
let n = Instant::now();
|
||||
assert_eq!(w.observe(&s(Some(true), &["app.one"]), n, true, true), None);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(true), &["app.one"]),
|
||||
n + Duration::from_secs(60),
|
||||
true,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
w.observe(&s(Some(true), &[]), n + Duration::from_secs(61), true, true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
w.observe(&s(Some(true), &[]), n + Duration::from_secs(75), true, true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
w.observe(&s(Some(true), &[]), n + Duration::from_secs(76), true, true),
|
||||
Some(Reason::AppExited)
|
||||
);
|
||||
w.pause();
|
||||
assert_eq!(
|
||||
w.observe(&s(Some(true), &[]), n + Duration::from_secs(90), true, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn native_window_close_is_independent_of_auto_exit_and_headless_windows_do_not_close() {
|
||||
let n = Instant::now();
|
||||
let mut w = Watch::default();
|
||||
assert_eq!(
|
||||
w.observe(&s(Some(false), &["app.one"]), n, false, true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(false), &["app.one"]),
|
||||
n + Duration::from_secs(60),
|
||||
false,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
w.observe(&s(Some(true), &["app.one"]), n, false, true);
|
||||
w.observe(&s(Some(false), &["app.one"]), n, false, true);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(false), &["app.one"]),
|
||||
n + Duration::from_secs(1),
|
||||
false,
|
||||
true
|
||||
),
|
||||
Some(Reason::WindowClosed)
|
||||
);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(false), &["app.one"]),
|
||||
n + Duration::from_secs(2),
|
||||
false,
|
||||
false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn restart_shared_apps_and_unknown_queries_cancel_stop() {
|
||||
let n = Instant::now();
|
||||
let mut w = Watch::default();
|
||||
w.observe(&s(Some(true), &["app.one"]), n, true, true);
|
||||
w.observe(&s(Some(false), &[]), n, true, true);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(false), &["app.two"]),
|
||||
n + Duration::from_secs(20),
|
||||
true,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
w.observe(&s(None, &[]), n + Duration::from_secs(21), true, true);
|
||||
assert_eq!(
|
||||
w.observe(
|
||||
&s(Some(false), &["app.one"]),
|
||||
n + Duration::from_secs(22),
|
||||
true,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
let mut restarted = s(Some(false), &[]);
|
||||
restarted.instance = "boot2".into();
|
||||
assert_eq!(
|
||||
w.observe(&restarted, n + Duration::from_secs(40), true, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Steam invokes its Lepton compatibility tool; a private entry adapter preserves
|
||||
//! existing storage/input settings without replacing any system/Steam files.
|
||||
use super::*;
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
const NATIVE_HOOKS: &str = r#"
|
||||
# The session service has verified the installed package before starting Steam.
|
||||
# Do not let native APK/depot timestamps reinstall or clear that installation.
|
||||
function is_app_baked() { return 0; }
|
||||
function get_app_mount_dir() { print "${FRAMELY_NATIVE_MOUNT:?}"; }
|
||||
function get_app_activity() { print "${FRAMELY_NATIVE_ACTIVITY:?}"; }
|
||||
# Android may otherwise reuse its desktop/freeform launch preference. Select
|
||||
# fullscreen before creating the activity, rather than resizing it afterwards.
|
||||
function run_app_commands() {
|
||||
local APP_ID="${1:-$(get_app_id)}"
|
||||
local APP_ACTIVITY="${2:-$(get_app_activity)}"
|
||||
if app_wants_flatscreen; then
|
||||
print "am start -S --windowingMode 1 ${APP_ID}/${APP_ACTIVITY} && /system/bin/sh /vendor/share/framely-window.sh ${APP_ID}"
|
||||
else
|
||||
print "am start -S ${APP_ID}/${APP_ACTIVITY}"
|
||||
fi
|
||||
}
|
||||
function get_app_hash() { extract_app_hash; }
|
||||
function get_app_last_depot_version() { extract_app_last_depot_version; }
|
||||
function clear_baked_app_data() { println 'Framely: keeping installed application data'; }
|
||||
# Separate native APK overlays in older shared contexts. Keep the installed
|
||||
# directory (including extracted libraries/splits), rather than hiding it with
|
||||
# a directory containing only the staged base APK.
|
||||
if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
|
||||
framely_native_mounts="$(declare -f setup_mounts)"
|
||||
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
|
||||
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
|
||||
[[ "$framely_native_mounts" == *"$framely_native_lower"* && "$framely_native_mounts" == *"$framely_native_upper"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
||||
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
|
||||
eval "$framely_native_mounts"
|
||||
unset framely_native_mounts framely_native_lower framely_native_upper
|
||||
fi
|
||||
"#;
|
||||
|
||||
pub(super) fn script(source: &str) -> Result<String> {
|
||||
let adapted = direct_launch_script(source)?;
|
||||
let include = "source \"${SCRIPT_DIR}/liblepton/liblepton.sh\"";
|
||||
// Append native overrides immediately before the entry's case statement,
|
||||
// after the shared resource, orientation and optional input hooks.
|
||||
let marker = "case \"${COMMAND}\" in";
|
||||
ensure!(
|
||||
source.matches(marker).count() == 1 && source.contains("function teardown()"),
|
||||
"Unsupported native Lepton entry; keeping the existing installation unchanged"
|
||||
);
|
||||
ensure!(adapted.contains(include), "Missing native Lepton library");
|
||||
Ok(adapted.replacen(marker, &format!("{NATIVE_HOOKS}\n{marker}"), 1))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_name(r: &Record) -> Option<String> {
|
||||
r.steam_binding.as_ref().filter(|b| b.native)?;
|
||||
r.steam_app_id
|
||||
.filter(|id| *id >= 0x80000000)
|
||||
.map(|id| format!("steamlaunch-{id}"))
|
||||
}
|
||||
|
||||
pub(super) fn prepare(
|
||||
home: &Path,
|
||||
id: &str,
|
||||
app_id: u32,
|
||||
) -> Result<(Container, App, Value, fs::File)> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
let _lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(init(home)?.join("operation.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let mut db = load(home)?;
|
||||
let r = db.records.get(id).context("Unknown APK entry")?;
|
||||
ensure!(
|
||||
r.steam_launch
|
||||
&& r.steam_binding.as_ref().is_some_and(|b| b.native)
|
||||
&& r.steam_app_id == Some(app_id),
|
||||
"Steam APK binding changed; refresh the entry and retry"
|
||||
);
|
||||
// Steam's launcher closes inherited descriptors. Keep this lock in the
|
||||
// session service, which owns it until the launch and container have ended.
|
||||
fs::create_dir_all(root(home).join("steam"))?;
|
||||
let ownership = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(
|
||||
root(home)
|
||||
.join("steam")
|
||||
.join(format!("context-{}.lock", hash(&r.context))),
|
||||
)?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another application is starting or running in this container"
|
||||
);
|
||||
let (a, mut c) = app(home, &db, id)?;
|
||||
ensure!(
|
||||
a.installed && a.state_known && a.pending.is_none(),
|
||||
"APK installation is unavailable or an operation is pending"
|
||||
);
|
||||
let component = launch_component(&a)?;
|
||||
// Also guard the original name: registration can occur while the old launch
|
||||
// is still running, and two runtime names must never share an overlay.
|
||||
let original = if c.id.starts_with("external-") {
|
||||
db.root_contexts
|
||||
.get(&c.baked)
|
||||
.cloned()
|
||||
.context("Unknown external container name")?
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
ensure!(
|
||||
c.name == original || !running(&c.name),
|
||||
"This application is already running through Steam"
|
||||
);
|
||||
let mut installer = c.clone();
|
||||
installer.name = original;
|
||||
let idle = if running(&installer.name) {
|
||||
Some(lifecycle::idle_instance(&installer)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
c.name = format!("steamlaunch-{app_id}");
|
||||
c.steam = false;
|
||||
let xml = fs::read_to_string(c.baked.join("data_overlay/system/packages.xml"))?;
|
||||
let mount = xml
|
||||
.split('<')
|
||||
.filter(|t| t.starts_with("package "))
|
||||
.find(|t| attr(t, "name") == Some(&a.metadata.package))
|
||||
.and_then(|t| attr(t, "codePath"))
|
||||
.filter(|p| p.starts_with("/data/") && !p.split('/').any(|x| x == ".."))
|
||||
.context("Cannot verify the installed APK mount; no application data was changed")?;
|
||||
let runner = runner(home)?;
|
||||
let path = root(home)
|
||||
.join("steam")
|
||||
.join(format!("native-{}.sh", hash(id)));
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
steam_shortcuts::write_owned(
|
||||
&path,
|
||||
script(&fs::read_to_string(&runner)?)?.as_bytes(),
|
||||
0o700,
|
||||
)?;
|
||||
storage::prepare(&c.baked)?;
|
||||
let media = c.baked.join("data_overlay/media/0");
|
||||
let adopted = c.baked.parent().unwrap().join("external");
|
||||
let external = if fs::read_link(&media).is_ok_and(|target| target == adopted) {
|
||||
ensure!(!adopted.is_symlink(), "Unexpected external media link");
|
||||
adopted
|
||||
} else {
|
||||
c.baked.join("external")
|
||||
};
|
||||
let flat = a.show_window.unwrap_or(!a.metadata.vr);
|
||||
let (window_dex, window_script) = window_runtime(home)?;
|
||||
let mut env = json!({
|
||||
"FRAMELY_WINDOW_DEX": window_dex,
|
||||
"FRAMELY_WINDOW_SCRIPT": window_script,
|
||||
"FRAMELY_LEPTON_DIR":runner.parent().context("Missing Lepton directory")?,
|
||||
"STEAM_COMPAT_DATA_PATH":c.baked.parent().context("Missing application data")?,
|
||||
"FRAMELY_NATIVE_MOUNT":mount,
|
||||
"FRAMELY_NATIVE_ACTIVITY":component.split_once('/').context("Invalid component")?.1,
|
||||
"FRAMELY_NATIVE_LAUNCH":"true",
|
||||
"FRAMELY_EXTERNAL_MEDIA_DIR":external,
|
||||
"FRAMELY_SHADER_CACHE_DIR":c.baked.join("shadercache"),
|
||||
"FRAMELY_BACKGROUND_BOOT":"false",
|
||||
"FRAMELY_WINDOW_ORIENTATION":a.orientation.as_deref().unwrap_or("auto"),
|
||||
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
|
||||
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
|
||||
});
|
||||
let installed_dir = c
|
||||
.baked
|
||||
.join("data_overlay")
|
||||
.join(mount.trim_start_matches("/data/"));
|
||||
if installed_dir.is_dir() {
|
||||
let installed_dir = fs::canonicalize(installed_dir)?;
|
||||
ensure!(
|
||||
installed_dir.starts_with(fs::canonicalize(c.baked.join("data_overlay"))?),
|
||||
"Installed APK directory escapes its data location"
|
||||
);
|
||||
env["FRAMELY_NATIVE_APK_DIR"] = json!(installed_dir);
|
||||
env["FRAMELY_NATIVE_OVERLAY"] = json!(c
|
||||
.baked
|
||||
.join(format!("native-apk-{}", hash(&a.metadata.package))));
|
||||
}
|
||||
// Older releases left the headless installation container alive. Transfer
|
||||
// only an unchanged, idle instance after validating the native launch setup.
|
||||
if let Some(identity) = idle {
|
||||
ensure!(
|
||||
lifecycle::idle_instance(&installer)? == identity,
|
||||
"Container changed during launch"
|
||||
);
|
||||
stop(
|
||||
&installer,
|
||||
&root(home)
|
||||
.join("logs")
|
||||
.join(format!("{}-native-handoff.log", hash(id))),
|
||||
)?;
|
||||
}
|
||||
if db.gamepad_enabled {
|
||||
let m =
|
||||
crate::gamepad::prepare(&root(home), &c.name, &db.gamepad_source, db.gamepad_rumble)?;
|
||||
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
|
||||
for (k, v) in [
|
||||
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
|
||||
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
|
||||
("FRAMELY_GAMEPAD_LAYOUT", json!(m.layout)),
|
||||
("FRAMELY_GAMEPAD_READY", json!(m.ready)),
|
||||
("FRAMELY_GAMEPAD_TOKEN", json!(m.token)),
|
||||
] {
|
||||
env[k] = v;
|
||||
}
|
||||
}
|
||||
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
|
||||
save(home, &db)?;
|
||||
Ok((
|
||||
c,
|
||||
a,
|
||||
json!({"script":path,"env":env,"transientGeneration":db.records[id].steam_transient_generation}),
|
||||
ownership,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Native APK launch must run as the Steam session user"
|
||||
);
|
||||
let home = crate::steam::home()?;
|
||||
let expected = runner(&home)?;
|
||||
let entry = command
|
||||
.len()
|
||||
.checked_sub(4)
|
||||
.context("Unexpected native Lepton command")?;
|
||||
ensure!(
|
||||
fs::canonicalize(&command[entry])? == fs::canonicalize(&expected)?
|
||||
&& command[entry + 1] == "waitforexitandrun"
|
||||
&& command[entry + 2] == "--",
|
||||
"Unexpected native Lepton command"
|
||||
);
|
||||
if entry != 0 {
|
||||
// %command% includes Steam's launch wrapper and reaper. Retain both;
|
||||
// replacing the whole command would lose native lifecycle ownership.
|
||||
ensure!(
|
||||
entry == 8
|
||||
&& Path::new(&command[0])
|
||||
.file_name()
|
||||
.is_some_and(|s| s == "steam-launch-wrapper")
|
||||
&& Path::new(&command[4])
|
||||
.file_name()
|
||||
.is_some_and(|s| s == "reaper")
|
||||
&& command[1] == "--oom-score-adjust"
|
||||
&& command[2].parse::<i32>().is_ok()
|
||||
&& command[3] == "--"
|
||||
&& command[5] == "SteamLaunch"
|
||||
&& command[6].starts_with("AppId=")
|
||||
&& command[7] == "--",
|
||||
"Unsupported Steam launch wrapper; keeping application data unchanged"
|
||||
);
|
||||
for i in [0, 4] {
|
||||
let path = fs::canonicalize(&command[i])?;
|
||||
ensure!(
|
||||
path.starts_with(fs::canonicalize(home.join(".local/share/Steam"))?),
|
||||
"Unexpected Steam launch runtime"
|
||||
);
|
||||
}
|
||||
}
|
||||
let r = load(&home)?
|
||||
.records
|
||||
.get(app)
|
||||
.cloned()
|
||||
.context("Unknown APK entry")?;
|
||||
ensure!(
|
||||
r.steam_token.as_deref() == Some(token),
|
||||
"Invalid native APK token"
|
||||
);
|
||||
let apk = steam_shortcuts::native_apk(&home, app);
|
||||
ensure!(
|
||||
fs::canonicalize(&command[entry + 3])? == fs::canonicalize(&apk)?,
|
||||
"Unexpected APK launch target"
|
||||
);
|
||||
let response = steam_bridge::native_call(
|
||||
app,
|
||||
token,
|
||||
r.steam_app_id.context("Missing Steam APK App ID")?,
|
||||
)?;
|
||||
let path = response["script"]
|
||||
.as_str()
|
||||
.context("Missing native entry")?;
|
||||
ensure!(
|
||||
Path::new(path)
|
||||
== root(&home)
|
||||
.join("steam")
|
||||
.join(format!("native-{}.sh", hash(app))),
|
||||
"Invalid native entry path"
|
||||
);
|
||||
let mut arguments = command.to_vec();
|
||||
arguments[entry] = path.to_owned();
|
||||
let mut cmd = Command::new(&arguments[0]);
|
||||
cmd.args(&arguments[1..])
|
||||
.env_remove("FRAMELY_STEAM_APP_ID")
|
||||
.env("SteamAppId", r.steam_app_id.unwrap().to_string());
|
||||
for (k, v) in response["env"]
|
||||
.as_object()
|
||||
.context("Missing native environment")?
|
||||
{
|
||||
cmd.env(k, v.as_str().context("Invalid native environment value")?);
|
||||
}
|
||||
Err(cmd.exec().into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn native_shared_context_uses_complete_installed_directory_and_separate_overlay() {
|
||||
let source = format!(
|
||||
r#"
|
||||
function print() {{ printf '%s\n' "$*"; }}
|
||||
function setup_mounts() {{
|
||||
APP_APK_DIR="$(dirname "$(get_apk_path)")"
|
||||
local APP_OVERLAY_UPPERDIR="$(get_baked_app_data_dir)/app_overlay"
|
||||
print "$APP_APK_DIR|$APP_OVERLAY_UPPERDIR|$(app_workdir)"
|
||||
}}
|
||||
{NATIVE_HOOKS}
|
||||
setup_mounts
|
||||
"#
|
||||
);
|
||||
for package in ["one", "two"] {
|
||||
let out = Command::new("bash")
|
||||
.args(["-c", &source])
|
||||
.env(
|
||||
"FRAMELY_NATIVE_APK_DIR",
|
||||
format!("/baked/data_overlay/app/{package}"),
|
||||
)
|
||||
.env("FRAMELY_NATIVE_OVERLAY", format!("/baked/native-{package}"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let lib = t.path().join("lepton/liblepton");
|
||||
fs::create_dir_all(&lib).unwrap();
|
||||
fs::write(
|
||||
lib.join("liblepton.sh"),
|
||||
r#"
|
||||
function data_mount_path() { echo "$TEST_DATA"; }
|
||||
function setup_props() { :; }
|
||||
function props_file() { echo "$TEST_PROPS"; }
|
||||
function setup_mounts() {
|
||||
rm -rf "$(data_mount_path)/media/0"
|
||||
mkdir -p "$(data_mount_path)/media"
|
||||
ln -s "${STEAM_COMPAT_DATA_PATH}/external" "$(data_mount_path)/media/0"
|
||||
}
|
||||
function setup_podman_mounts() { setup_mounts; }
|
||||
function podman_mount_entry() { :; }
|
||||
function print() { printf '%s\n' "$*"; }
|
||||
function println() { print "$@"; }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let source = r#"#!/bin/bash
|
||||
set -euo pipefail
|
||||
COMMAND=waitforexitandrun
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
function teardown() { clear_baked_app_data 'early exit'; }
|
||||
case "${COMMAND}" in
|
||||
waitforexitandrun)
|
||||
is_app_baked
|
||||
setup_podman_mounts
|
||||
get_app_mount_dir
|
||||
get_app_activity
|
||||
run_app_commands test.Package test.Selected
|
||||
teardown
|
||||
;;
|
||||
esac
|
||||
"#;
|
||||
let entry = t.path().join("entry.sh");
|
||||
fs::write(&entry, script(source).unwrap()).unwrap();
|
||||
let compat = t.path().join("context");
|
||||
let data = compat.join("baked/data_overlay");
|
||||
let download = data.join("media/0/Android/data/test/files/download.part");
|
||||
fs::create_dir_all(download.parent().unwrap()).unwrap();
|
||||
fs::write(&download, b"resume-me").unwrap();
|
||||
let save = data.join("data/test/save");
|
||||
fs::create_dir_all(save.parent().unwrap()).unwrap();
|
||||
fs::write(&save, b"saved game").unwrap();
|
||||
for flat in [true, false] {
|
||||
let out = Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
|
||||
.env("STEAM_COMPAT_DATA_PATH", &compat)
|
||||
.env("FRAMELY_EXTERNAL_MEDIA_DIR", compat.join("baked/external"))
|
||||
.env("FRAMELY_SHADER_CACHE_DIR", compat.join("baked/shadercache"))
|
||||
.env("FRAMELY_NATIVE_LAUNCH", "true")
|
||||
.env("FRAMELY_NATIVE_MOUNT", "/data/app/test")
|
||||
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
|
||||
.env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" })
|
||||
.env("TEST_DATA", &data)
|
||||
.env("TEST_PROPS", t.path().join("props"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("test.Selected"));
|
||||
let expected = if flat {
|
||||
"am start -S --windowingMode 1 test.Package/test.Selected"
|
||||
} else {
|
||||
"am start -S test.Package/test.Selected"
|
||||
};
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(expected));
|
||||
assert_eq!(fs::read(&save).unwrap(), b"saved game");
|
||||
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
|
||||
}
|
||||
assert!(script("unknown future native entry").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
//! Steam-owned wrapper lifetime. Only the session service launches/controls APKs.
|
||||
use super::steam_shortcuts as shortcuts;
|
||||
use super::*;
|
||||
use crate::ipc;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{BufRead, BufReader, Read},
|
||||
os::unix::net::{UnixListener, UnixStream},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
};
|
||||
type Progress = Arc<dyn Fn(Value) + Send + Sync>;
|
||||
struct Pending {
|
||||
ticket: u64,
|
||||
progress: Progress,
|
||||
result: mpsc::Sender<std::result::Result<Value, String>>,
|
||||
}
|
||||
static PENDING: Mutex<BTreeMap<String, Pending>> = Mutex::new(BTreeMap::new());
|
||||
static STARTING: Mutex<std::collections::BTreeSet<String>> =
|
||||
Mutex::new(std::collections::BTreeSet::new());
|
||||
static STOPPED: AtomicBool = AtomicBool::new(false);
|
||||
extern "C" fn interrupted(_: libc::c_int) {
|
||||
STOPPED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Lease {
|
||||
app: String,
|
||||
token: String,
|
||||
pid: u32,
|
||||
signature: String,
|
||||
instance: String,
|
||||
}
|
||||
fn leases(home: &Path) -> PathBuf {
|
||||
root(home).join("steam/leases")
|
||||
}
|
||||
fn lease_path(home: &Path, id: &str) -> PathBuf {
|
||||
leases(home).join(format!("{}.json", hash(id)))
|
||||
}
|
||||
fn signature(pid: u32) -> Option<String> {
|
||||
if !runtime_pid_alive(&pid.to_string()) {
|
||||
return None;
|
||||
}
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let start = stat.rsplit_once(')')?.1.split_whitespace().nth(19)?;
|
||||
let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?;
|
||||
Some(format!("{}:{start}", boot.trim()))
|
||||
}
|
||||
fn runtime() -> Result<PathBuf> {
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
ensure!(uid != 0, "Steam APK wrapper must run as the session user");
|
||||
let p = PathBuf::from(format!("/run/user/{uid}/framely-apk-steam"));
|
||||
fs::create_dir_all(&p)?;
|
||||
let m = fs::symlink_metadata(&p)?;
|
||||
ensure!(
|
||||
m.is_dir() && m.uid() == uid,
|
||||
"Invalid Steam wrapper runtime directory"
|
||||
);
|
||||
fs::set_permissions(&p, fs::Permissions::from_mode(0o700))?;
|
||||
Ok(p)
|
||||
}
|
||||
fn socket() -> Result<PathBuf> {
|
||||
Ok(runtime()?.join("control.sock"))
|
||||
}
|
||||
fn read_request(stream: &mut UnixStream) -> Result<Value> {
|
||||
let mut bytes = Vec::new();
|
||||
BufReader::new(stream)
|
||||
.take(8193)
|
||||
.read_until(b'\n', &mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 8192 && bytes.last() == Some(&b'\n'),
|
||||
"Invalid Steam wrapper request"
|
||||
);
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
fn peer(stream: &UnixStream) -> Result<u32> {
|
||||
let mut c: libc::ucred = unsafe { std::mem::zeroed() };
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
ensure!(
|
||||
unsafe {
|
||||
libc::getsockopt(
|
||||
stream.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
&mut c as *mut _ as *mut _,
|
||||
&mut len,
|
||||
)
|
||||
} == 0
|
||||
&& c.uid == unsafe { libc::geteuid() }
|
||||
&& c.pid > 0,
|
||||
"Invalid Steam wrapper peer"
|
||||
);
|
||||
Ok(c.pid as u32)
|
||||
}
|
||||
fn record(home: &Path, id: &str, token: &str) -> Result<Record> {
|
||||
let db = load(home)?;
|
||||
let r = db.records.get(id).context("Unknown Steam APK entry")?;
|
||||
ensure!(
|
||||
r.steam_token.as_deref() == Some(token),
|
||||
"Invalid Steam APK entry token"
|
||||
);
|
||||
Ok(r.clone())
|
||||
}
|
||||
pub(super) fn current_instance(c: &Container) -> Result<Option<String>> {
|
||||
let mut cmd = crate::process::tool("podman");
|
||||
cmd.args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{/*SteamBridge*/}}{{.Id}}|{{.State.StartedAt}}|{{.State.Running}}|{{.State.Pid}}",
|
||||
&format!("lepton-{}", c.name),
|
||||
]);
|
||||
let text = match output(cmd, Duration::from_secs(3), None) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
// Lepton removes its transient Podman object when stopped. Only
|
||||
// Podman's explicit "absent" exit code is safe to treat as stopped;
|
||||
// permission/storage/runtime errors must not authorize a new boot.
|
||||
let exists = crate::process::command_output_timeout(
|
||||
crate::process::tool("podman").args([
|
||||
"container",
|
||||
"exists",
|
||||
&format!("lepton-{}", c.name),
|
||||
]),
|
||||
Duration::from_secs(3),
|
||||
false,
|
||||
)?;
|
||||
if exists.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(error.context("Cannot verify Steam APK container state"));
|
||||
}
|
||||
};
|
||||
let fields: Vec<_> = text.trim().split('|').collect();
|
||||
ensure!(
|
||||
fields.len() == 4
|
||||
&& !fields[0].is_empty()
|
||||
&& !fields[1].is_empty()
|
||||
&& matches!(fields[2], "true" | "false"),
|
||||
"Unknown Steam container state"
|
||||
);
|
||||
let pid = fields[3]
|
||||
.parse::<u32>()
|
||||
.context("Unknown container process")?;
|
||||
if fields[2] == "false" || pid == 0 || !runtime_pid_alive(&pid.to_string()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(fields[..3].join("|")))
|
||||
}
|
||||
fn instance(c: &Container) -> Result<String> {
|
||||
current_instance(c)?.context("APK container has stopped")
|
||||
}
|
||||
fn save_lease(home: &Path, l: &Lease) -> Result<()> {
|
||||
fs::create_dir_all(leases(home))?;
|
||||
fs::set_permissions(leases(home), fs::Permissions::from_mode(0o700))?;
|
||||
let p = lease_path(home, &l.app);
|
||||
let tmp = p.with_extension(format!("{}.tmp", std::process::id()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?;
|
||||
file.write_all(&serde_json::to_vec(l)?)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(tmp, p)?;
|
||||
Ok(())
|
||||
}
|
||||
fn load_lease(home: &Path, id: &str) -> Result<Lease> {
|
||||
let p = lease_path(home, id);
|
||||
ensure!(
|
||||
fs::metadata(&p)?.len() <= 8192,
|
||||
"Invalid Steam wrapper lease"
|
||||
);
|
||||
Ok(serde_json::from_slice(&fs::read(p)?)?)
|
||||
}
|
||||
fn owned_status(home: &Path, l: &Lease) -> Result<Value> {
|
||||
let db = load(home)?;
|
||||
if db.records.get(&l.app).is_none_or(|r| r.removed) {
|
||||
return Ok(json!({"owned":false}));
|
||||
}
|
||||
let (_, c) = app(home, &db, &l.app)?;
|
||||
if current_instance(&c)?.as_deref() != Some(&l.instance) {
|
||||
return Ok(json!({"owned":false}));
|
||||
}
|
||||
let s = lifecycle::sample(&c)?;
|
||||
let alive = s.alive.contains(&db.records[&l.app].metadata.package);
|
||||
Ok(json!({"owned":true,"alive":alive}))
|
||||
}
|
||||
/// Stop only the app owned by this exact container instance. Other installed
|
||||
/// applications in an old shared context retain their processes and data.
|
||||
fn stop_owned(home: &Path, l: &Lease) -> Result<()> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(root(home).join("operation.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let db = load(home)?;
|
||||
if db.records.get(&l.app).is_none_or(|r| r.removed) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = record(home, &l.app, &l.token)?;
|
||||
ensure!(
|
||||
!db.records
|
||||
.values()
|
||||
.any(|r| r.context == db.records[&l.app].context && r.pending.is_some()),
|
||||
"APK operation is pending"
|
||||
);
|
||||
let (a, c) = app(home, &db, &l.app)?;
|
||||
if current_instance(&c)?.as_deref() != Some(&l.instance) {
|
||||
return Ok(());
|
||||
}
|
||||
let log = root(home)
|
||||
.join("logs")
|
||||
.join(format!("{}-steam-stop-{}.log", hash(&l.app), now()));
|
||||
fs::write(
|
||||
&log,
|
||||
"Steam wrapper stopped; preserving installed application data\n",
|
||||
)?;
|
||||
let before = lifecycle::sample(&c)?;
|
||||
crate::gamepad::stop_app(&c.name, &r.metadata.package);
|
||||
if before.package == a.metadata.package {
|
||||
podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"setprop",
|
||||
"waydroid.active_apps",
|
||||
"none",
|
||||
],
|
||||
Some(&log),
|
||||
)?;
|
||||
}
|
||||
podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"am",
|
||||
"force-stop",
|
||||
"--user",
|
||||
"0",
|
||||
&a.metadata.package,
|
||||
],
|
||||
Some(&log),
|
||||
)?;
|
||||
let after = lifecycle::sample(&c)?;
|
||||
if after.instance == l.instance && after.alive.is_empty() {
|
||||
stop(&c, &log)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
|
||||
let id = v["app"].as_str().context("Missing Steam APK entry")?;
|
||||
let token = v["token"]
|
||||
.as_str()
|
||||
.context("Missing Steam APK entry token")?;
|
||||
match v["method"].as_str() {
|
||||
Some("native.prepare") => {
|
||||
let r = record(home, id, token)?;
|
||||
ensure!(
|
||||
!r.removed && r.steam_launch && shortcuts::linked(home, &r),
|
||||
"Steam launch is disabled"
|
||||
);
|
||||
let app_id = v["steamAppId"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v >= 0x80000000)
|
||||
.context("Launch this APK entry through Steam")?;
|
||||
let (c, a, response, ownership) = super::native::prepare(home, id, app_id)?;
|
||||
let owner_signature = signature(pid).context("Steam launch exited")?;
|
||||
let home = home.to_owned();
|
||||
let id = id.to_owned();
|
||||
let token = token.to_owned();
|
||||
let transient_generation = response["transientGeneration"].as_str().map(str::to_owned);
|
||||
let gamepad_event = response["env"]["FRAMELY_GAMEPAD_EVENT"]
|
||||
.as_str()
|
||||
.map(PathBuf::from);
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Result<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let creation_deadline = Instant::now() + Duration::from_secs(45);
|
||||
let mut observation = "waiting for container creation".to_owned();
|
||||
loop {
|
||||
ensure!(
|
||||
signature(pid).as_deref() == Some(&owner_signature),
|
||||
"Native Lepton launch exited; inspect the APK log"
|
||||
);
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Native Lepton startup timed out: {observation}; inspect the APK log"
|
||||
);
|
||||
let instance = current_instance(&c)?;
|
||||
if instance.is_none() {
|
||||
if let Some(event) = &gamepad_event {
|
||||
ensure!(event.exists(), "Virtual gamepad disappeared before Lepton created its container; retry the launch");
|
||||
}
|
||||
ensure!(Instant::now() < creation_deadline, "Lepton did not create its container; inspect the native Lepton startup log");
|
||||
}
|
||||
if let Some(instance) = instance {
|
||||
match lifecycle::sample(&c) {
|
||||
Ok(s) => {
|
||||
observation = format!(
|
||||
"active package {}, target process present={}",
|
||||
s.package,
|
||||
s.alive.contains(&a.metadata.package)
|
||||
);
|
||||
if s.package == a.metadata.package
|
||||
&& s.alive.contains(&a.metadata.package)
|
||||
{
|
||||
save_lease(
|
||||
&home,
|
||||
&Lease {
|
||||
app: id.clone(),
|
||||
token: token.clone(),
|
||||
pid,
|
||||
signature: owner_signature.clone(),
|
||||
instance,
|
||||
},
|
||||
)?;
|
||||
if crate::gamepad::current(&c.name).is_some() {
|
||||
crate::gamepad::activate(&c.name, &a.metadata.package)?;
|
||||
}
|
||||
return Ok(json!({"started":true,"native":true}));
|
||||
}
|
||||
}
|
||||
Err(e) => observation = format!("lifecycle check failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
})();
|
||||
if let Err(e) = &result {
|
||||
// Also report launches made directly from Steam, which
|
||||
// have no pending panel job to receive this error.
|
||||
eprintln!(
|
||||
"Native APK startup failed for {} (owner PID {pid}): {e:#}",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
// A failed Podman run can leave Lepton waiting for a create
|
||||
// event forever. End only this verified, still-uncreated launch;
|
||||
// never interrupt a container that has already booted.
|
||||
if result.is_err() && matches!(current_instance(&c), Ok(None)) {
|
||||
if signature(pid).as_deref() == Some(&owner_signature) {
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
crate::gamepad::stop_context(&c.name);
|
||||
}
|
||||
if let Some(p) = PENDING.lock().unwrap().remove(&id) {
|
||||
if result.is_ok() {
|
||||
(p.progress)(json!({"phase":"started"}));
|
||||
}
|
||||
let _ = p.result.send(result.map_err(|e| format!("{e:#}")));
|
||||
}
|
||||
// Preserve storage ownership after reporting startup success.
|
||||
// Steam drops inherited fds, so the service retains this lock
|
||||
// through exit and the container's eventual shutdown.
|
||||
while signature(pid).as_deref() == Some(&owner_signature)
|
||||
|| !matches!(current_instance(&c), Ok(None))
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
// The native Lepton entry may remove its container before the
|
||||
// lease watcher runs. Release its virtual device here, while
|
||||
// we still hold the context lock, so a later launch cannot be
|
||||
// affected by an old helper or have its new device removed.
|
||||
crate::gamepad::stop_context(&c.name);
|
||||
drop(ownership);
|
||||
// A generation protects a newer launch from an old exit. A
|
||||
// persistent entry switched to transient mode has no generation.
|
||||
let _ = shortcuts::cleanup_transient(&home, &id, transient_generation.as_deref());
|
||||
});
|
||||
Ok(response)
|
||||
}
|
||||
Some("start") => {
|
||||
let r = record(home, id, token)?;
|
||||
ensure!(
|
||||
!r.removed && r.steam_launch && shortcuts::linked(home, &r),
|
||||
"Steam launch is disabled for this APK"
|
||||
);
|
||||
let app_id = v["steamAppId"]
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v >= 0x80000000)
|
||||
.context("Launch this APK entry through Steam")?;
|
||||
if let Ok(old) = load_lease(home, id) {
|
||||
ensure!(
|
||||
signature(old.pid).as_deref() != Some(&old.signature),
|
||||
"This APK already has an active Steam wrapper"
|
||||
);
|
||||
}
|
||||
let entry_lock = shortcuts::wrapper(home, id).with_extension("launch.lock");
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(entry_lock)?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Steam APK launch is already in progress"
|
||||
);
|
||||
let db = load(home)?;
|
||||
let (_a, c) = app(home, &db, id)?;
|
||||
ensure!(current_instance(&c)?.is_none(),"Close the running container before its first Steam launch; existing applications will not be restarted automatically");
|
||||
let progress = PENDING
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|p| p.progress.clone())
|
||||
.unwrap_or_else(|| Arc::new(|_| {}));
|
||||
let signature = signature(pid).context("Steam wrapper exited before launch")?;
|
||||
let owner_home = home.to_owned();
|
||||
let owner_app = id.to_owned();
|
||||
let owner_token = token.to_owned();
|
||||
let owner_context = c.clone();
|
||||
let marked = std::sync::atomic::AtomicBool::new(false);
|
||||
let owner_progress: Progress = Arc::new(move |v| {
|
||||
if matches!(v["phase"].as_str(), Some("launching" | "started"))
|
||||
&& !marked.load(Ordering::Relaxed)
|
||||
{
|
||||
if let Ok(instance) = instance(&owner_context) {
|
||||
let lease = Lease {
|
||||
app: owner_app.clone(),
|
||||
token: owner_token.clone(),
|
||||
pid,
|
||||
signature: signature.clone(),
|
||||
instance,
|
||||
};
|
||||
if save_lease(&owner_home, &lease).is_ok() {
|
||||
marked.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
progress(v);
|
||||
});
|
||||
let launched = operate_internal(
|
||||
home,
|
||||
"launch",
|
||||
&json!({"app":id}),
|
||||
Cancellation::default(),
|
||||
owner_progress,
|
||||
Some(app_id),
|
||||
);
|
||||
let response = launched.and_then(|_| {
|
||||
let lease =
|
||||
load_lease(home, id).context("Cannot record the Steam-owned container")?;
|
||||
ensure!(
|
||||
lease.pid == pid && instance(&c)? == lease.instance,
|
||||
"Steam-owned container changed during startup"
|
||||
);
|
||||
Ok(json!({"started":true}))
|
||||
});
|
||||
if response.is_err() {
|
||||
if let Ok(lease) = load_lease(home, id) {
|
||||
if lease.pid == pid && stop_owned(home, &lease).is_ok() {
|
||||
let _ = fs::remove_file(lease_path(home, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = PENDING.lock().unwrap().remove(id) {
|
||||
let _ = p.result.send(
|
||||
response
|
||||
.as_ref()
|
||||
.map(Clone::clone)
|
||||
.map_err(|e| format!("{e:#}")),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
Some("status") | Some("stop") => {
|
||||
let l = load_lease(home, id)?;
|
||||
ensure!(
|
||||
l.pid == pid && l.token == token && signature(pid).as_deref() == Some(&l.signature),
|
||||
"Steam wrapper lease does not belong to this process"
|
||||
);
|
||||
if v["method"] == "stop" {
|
||||
stop_owned(home, &l)?;
|
||||
let _ = fs::remove_file(lease_path(home, id));
|
||||
Ok(json!(true))
|
||||
} else {
|
||||
owned_status(home, &l)
|
||||
}
|
||||
}
|
||||
_ => bail!("Invalid Steam wrapper method"),
|
||||
}
|
||||
}
|
||||
pub(super) fn start_session() -> Result<()> {
|
||||
let home = crate::steam::home()?;
|
||||
let dir = runtime()?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(dir.join("server.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Steam APK bridge is already running"
|
||||
);
|
||||
let path = socket()?;
|
||||
if path.exists() {
|
||||
ensure!(
|
||||
fs::symlink_metadata(&path)?.file_type().is_socket(),
|
||||
"Invalid Steam APK socket"
|
||||
);
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
||||
std::thread::spawn(move || {
|
||||
let _lock = lock;
|
||||
let workers = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
if workers.fetch_add(1, Ordering::Relaxed) >= 8 {
|
||||
workers.fetch_sub(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let home = home.clone();
|
||||
let workers = workers.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||
let result = peer(&stream).and_then(|pid| {
|
||||
read_request(&mut stream).and_then(|v| {
|
||||
let id = v["app"].as_str().unwrap_or("").to_owned();
|
||||
let start = v["method"] == "start" || v["method"] == "native.prepare";
|
||||
if start {
|
||||
record(&home, &id, v["token"].as_str().unwrap_or(""))?;
|
||||
ensure!(
|
||||
STARTING.lock().unwrap().insert(id.clone()),
|
||||
"Steam APK launch is already in progress"
|
||||
);
|
||||
}
|
||||
let result = handle(&home, pid, v);
|
||||
if start {
|
||||
if result.is_err() {
|
||||
if let Some(p) = PENDING.lock().unwrap().remove(&id) {
|
||||
let _ = p
|
||||
.result
|
||||
.send(Err(format!("{:#}", result.as_ref().unwrap_err())));
|
||||
}
|
||||
}
|
||||
STARTING.lock().unwrap().remove(&id);
|
||||
}
|
||||
result
|
||||
})
|
||||
});
|
||||
let _ = ipc::write(&mut stream, &ipc::response(result));
|
||||
workers.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
let home = crate::steam::home()?;
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(files) = fs::read_dir(leases(&home)) {
|
||||
for entry in files.flatten().take(64) {
|
||||
if entry.path().extension().is_none_or(|s| s != "json")
|
||||
|| !entry
|
||||
.metadata()
|
||||
.is_ok_and(|m| m.is_file() && m.len() <= 8192)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = fs::read(entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(l) = serde_json::from_slice::<Lease>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if entry.path() != lease_path(&home, &l.app) {
|
||||
continue;
|
||||
}
|
||||
if signature(l.pid).as_deref() != Some(&l.signature) {
|
||||
if load(&home).is_ok_and(|db| !db.records.contains_key(&l.app)) {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
continue;
|
||||
}
|
||||
if stop_owned(&home, &l).is_ok() {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn focus_owned(home: &Path, lease: &Lease, progress: &Progress) -> Result<Value> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(root(home).join("operation.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let db = load(home)?;
|
||||
let (a, c) = app(home, &db, &lease.app)?;
|
||||
ensure!(
|
||||
signature(lease.pid).as_deref() == Some(&lease.signature)
|
||||
&& current_instance(&c)?.as_deref() == Some(&lease.instance),
|
||||
"Previous Steam launch is closing; retry after it stops"
|
||||
);
|
||||
let log =
|
||||
root(home)
|
||||
.join("logs")
|
||||
.join(format!("{}-steam-focus-{}.log", hash(&lease.app), now()));
|
||||
launch_with_started(&c, &a, &log, || progress(json!({"phase":"started"})))?;
|
||||
if crate::gamepad::current(&c.name).is_some() {
|
||||
crate::gamepad::activate(&c.name, &a.metadata.package)?;
|
||||
}
|
||||
Ok(json!({"started":true,"reused":true}))
|
||||
}
|
||||
pub(super) fn request_launch(
|
||||
home: &Path,
|
||||
id: &str,
|
||||
progress: Progress,
|
||||
cancel: &Cancellation,
|
||||
) -> Result<Value> {
|
||||
let db = load(home)?;
|
||||
let r = db.records.get(id).context("APK entry not found")?;
|
||||
ensure!(
|
||||
r.steam_launch && shortcuts::linked(home, r),
|
||||
"Steam entry is missing; re-enable Steam launch in APK management"
|
||||
);
|
||||
let binding = r.steam_binding.as_ref().context("Missing Steam binding")?;
|
||||
if let Ok(lease) = load_lease(home, id) {
|
||||
if signature(lease.pid).as_deref() == Some(&lease.signature) {
|
||||
return cancel.commit(|| {
|
||||
shortcuts::rpc(home, "run-game", Some(&binding.game_id))?;
|
||||
progress(json!({"cancellable":false}));
|
||||
focus_owned(home, &lease, &progress)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let ticket = rand::random::<u64>();
|
||||
{
|
||||
let mut p = PENDING.lock().unwrap();
|
||||
ensure!(
|
||||
!p.contains_key(id) && !STARTING.lock().unwrap().contains(id),
|
||||
"This APK is already launching through Steam"
|
||||
);
|
||||
p.insert(
|
||||
id.into(),
|
||||
Pending {
|
||||
ticket,
|
||||
progress: progress.clone(),
|
||||
result: tx,
|
||||
},
|
||||
);
|
||||
}
|
||||
let result = (|| {
|
||||
cancel.commit(|| shortcuts::rpc(home, "run-game", Some(&binding.game_id)))?;
|
||||
progress(json!({"cancellable":false}));
|
||||
progress(json!({"phase":"starting"}));
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(result) => return result.map_err(anyhow::Error::msg),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("Steam APK launch connection closed"),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => ensure!(Instant::now()<deadline,"Steam did not start the APK entry. Check its compatibility and launch options in Steam."),
|
||||
}
|
||||
}
|
||||
})();
|
||||
{
|
||||
let mut pending = PENDING.lock().unwrap();
|
||||
if pending.get(id).is_some_and(|p| p.ticket == ticket) {
|
||||
pending.remove(id);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
pub(super) fn native_call(app: &str, token: &str, app_id: u32) -> Result<Value> {
|
||||
call_with_id(app, token, "native.prepare", 15, Some(app_id))
|
||||
}
|
||||
fn call(app: &str, token: &str, method: &str, timeout: u64) -> Result<Value> {
|
||||
call_with_id(app, token, method, timeout, None)
|
||||
}
|
||||
fn call_with_id(
|
||||
app: &str,
|
||||
token: &str,
|
||||
method: &str,
|
||||
timeout: u64,
|
||||
owner: Option<u32>,
|
||||
) -> Result<Value> {
|
||||
let mut s = UnixStream::connect(socket()?).context("Framely UI session is unavailable")?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(timeout)))?;
|
||||
s.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
let app_id = owner.or_else(|| {
|
||||
std::env::var("SteamAppId")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
});
|
||||
ipc::write(
|
||||
&mut s,
|
||||
&json!({"method":method,"app":app,"token":token,"steamAppId":app_id}),
|
||||
)?;
|
||||
let r = ipc::read(&mut s)?;
|
||||
if let Some(e) = r["error"].as_str() {
|
||||
bail!("{e}")
|
||||
}
|
||||
Ok(r["result"].clone())
|
||||
}
|
||||
pub(crate) fn run(app: &str, token: &str) -> Result<()> {
|
||||
STOPPED.store(false, Ordering::Relaxed);
|
||||
unsafe {
|
||||
libc::signal(
|
||||
libc::SIGTERM,
|
||||
interrupted as *const () as libc::sighandler_t,
|
||||
);
|
||||
libc::signal(libc::SIGINT, interrupted as *const () as libc::sighandler_t);
|
||||
libc::signal(libc::SIGHUP, interrupted as *const () as libc::sighandler_t);
|
||||
}
|
||||
// Duplicate shortcut executions cannot interfere with an existing wrapper.
|
||||
let home = crate::steam::home()?;
|
||||
let p = shortcuts::wrapper(&home, app).with_extension("owner.lock");
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(p)?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"This APK already has an active Steam wrapper"
|
||||
);
|
||||
call(app, token, "start", 180)?;
|
||||
let mut absent = None;
|
||||
while !STOPPED.load(Ordering::Relaxed) {
|
||||
match call(app, token, "status", 5) {
|
||||
Ok(v) if v["owned"] == false => return Ok(()),
|
||||
Ok(v) if v["alive"] == false => {
|
||||
let t = *absent.get_or_insert_with(Instant::now);
|
||||
if t.elapsed() >= Duration::from_secs(15) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => absent = None,
|
||||
}
|
||||
for _ in 0..20 {
|
||||
if STOPPED.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
// The session's persisted owner-PID monitor also handles SIGKILL and reconnects
|
||||
// across Framely updates. A failed stop request must not trigger a reinstall.
|
||||
let _ = call(app, token, "stop", 35);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_request(home: &Path, app: &str, token: &str, method: &str) -> Result<Value> {
|
||||
if method == "focus" {
|
||||
let progress: Progress = Arc::new(|_| {});
|
||||
return focus_owned(home, &load_lease(home, app)?, &progress);
|
||||
}
|
||||
handle(
|
||||
home,
|
||||
std::process::id(),
|
||||
json!({"app":app,"token":token,"method":method,"steamAppId":0x92345678u32}),
|
||||
)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn owner_signature_is_bound_to_process_start() {
|
||||
let pid = std::process::id();
|
||||
let a = signature(pid).unwrap();
|
||||
assert_eq!(signature(pid), Some(a));
|
||||
assert!(signature(u32::MAX).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unix_peer_and_bounded_requests() {
|
||||
let (mut a, mut b) = UnixStream::pair().unwrap();
|
||||
assert_eq!(peer(&a).unwrap(), std::process::id());
|
||||
ipc::write(&mut b, &json!({"method":"status"})).unwrap();
|
||||
assert_eq!(read_request(&mut a).unwrap()["method"], "status");
|
||||
b.write_all(&vec![b'x'; 8193]).unwrap();
|
||||
assert!(read_request(&mut a).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_adopt(home: &Path, id: &str, token: &str) -> Result<()> {
|
||||
let db = load(home)?;
|
||||
let (_, c) = app(home, &db, id)?;
|
||||
save_lease(
|
||||
home,
|
||||
&Lease {
|
||||
app: id.into(),
|
||||
token: token.into(),
|
||||
pid: std::process::id(),
|
||||
signature: signature(std::process::id()).unwrap(),
|
||||
instance: instance(&c)?,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! Valve Devkit IPC registration; never edits Steam's live shortcuts database.
|
||||
use super::*;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct Binding {
|
||||
pub game_id: String,
|
||||
#[serde(default)]
|
||||
pub native: bool,
|
||||
#[serde(default)]
|
||||
pub revision: String,
|
||||
}
|
||||
pub(super) fn game_id(id: &str) -> String {
|
||||
format!("framely{}", hash(id))
|
||||
}
|
||||
pub(super) fn native_apk(home: &Path, id: &str) -> PathBuf {
|
||||
home.join("devkit-game").join(game_id(id)).join("base.apk")
|
||||
}
|
||||
pub(super) fn wrapper(home: &Path, id: &str) -> PathBuf {
|
||||
home.join("devkit-game").join(game_id(id)).join("launch.sh")
|
||||
}
|
||||
pub(super) fn quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
pub(super) fn write_owned(p: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
let parent = p.parent().context("Missing entry parent")?;
|
||||
ensure!(
|
||||
fs::symlink_metadata(parent)?.is_dir(),
|
||||
"Invalid Devkit entry directory"
|
||||
);
|
||||
if let Ok(m) = fs::symlink_metadata(p) {
|
||||
ensure!(
|
||||
m.is_file() && !m.file_type().is_symlink(),
|
||||
"Invalid Devkit entry file"
|
||||
);
|
||||
}
|
||||
let tmp = parent.join(format!(
|
||||
".framely-{}.tmp",
|
||||
hex::encode(rand::random::<[u8; 16]>())
|
||||
));
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(mode)
|
||||
.open(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
fs::rename(&tmp, p)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Same authenticated local pipe protocol as Valve's steam-devkit-rpc.
|
||||
/// Token and complete command must never be logged.
|
||||
pub(super) fn rpc(home: &Path, command: &str, id: Option<&str>) -> Result<String> {
|
||||
ensure!(
|
||||
matches!(
|
||||
command,
|
||||
"create-shortcut" | "list-shortcuts" | "run-game" | "delete-shortcut"
|
||||
),
|
||||
"Invalid Devkit command"
|
||||
);
|
||||
if let Some(id) = id {
|
||||
ensure!(
|
||||
!id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'),
|
||||
"Invalid Devkit game ID"
|
||||
);
|
||||
}
|
||||
let steam = home.join(".steam");
|
||||
let token_path = steam.join("steam.token");
|
||||
ensure!(
|
||||
fs::metadata(&token_path)?.len() <= 256,
|
||||
"Invalid Steam authentication file"
|
||||
);
|
||||
let token = fs::read_to_string(token_path)?;
|
||||
let token = token.trim();
|
||||
ensure!(
|
||||
!token.is_empty() && token.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"Invalid Steam authentication token"
|
||||
);
|
||||
let pid = fs::read_to_string(steam.join("steam.pid"))?;
|
||||
ensure!(runtime_pid_alive(pid.trim()), "Steam is not running");
|
||||
// Steam's Devkit response writer accepts /tmp paths, but silently ignores
|
||||
// responses in the application's persistent home directory on Frame.
|
||||
// Match Valve's helper, using a private directory independent of TMPDIR.
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("framely-steam-rpc-")
|
||||
.tempdir_in("/tmp")?;
|
||||
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?;
|
||||
let response = directory.path().join("response");
|
||||
let mut q = url::form_urlencoded::Serializer::new(String::new());
|
||||
q.append_pair(
|
||||
"response",
|
||||
response.to_str().context("Invalid response path")?,
|
||||
);
|
||||
if let Some(id) = id {
|
||||
q.append_pair("gameid", id);
|
||||
}
|
||||
q.append_pair(
|
||||
"directory",
|
||||
home.join("devkit-game")
|
||||
.to_str()
|
||||
.context("Invalid Devkit path")?,
|
||||
);
|
||||
let pipe_path = steam.join("steam.pipe");
|
||||
ensure!(
|
||||
fs::metadata(&pipe_path)?.file_type().is_fifo(),
|
||||
"Steam IPC pipe is unavailable"
|
||||
);
|
||||
let mut pipe = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(pipe_path)
|
||||
.context("Steam IPC is unavailable")?;
|
||||
let command = format!(
|
||||
"devkit-1 steam://devkit-1/{token}/{command}/?{}\n",
|
||||
q.finish()
|
||||
);
|
||||
ensure!(command.len() < 4096, "Devkit request too large");
|
||||
pipe.write_all(command.as_bytes())?;
|
||||
drop(pipe);
|
||||
let error = response.with_extension("error");
|
||||
let lock = response.with_extension("lock");
|
||||
let result = (|| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if error.exists() {
|
||||
ensure!(
|
||||
fs::metadata(&error)?.len() <= 8192,
|
||||
"Invalid Steam response"
|
||||
);
|
||||
bail!("Steam Devkit: {}", fs::read_to_string(&error)?.trim());
|
||||
}
|
||||
if response.exists() && !lock.exists() {
|
||||
ensure!(
|
||||
fs::metadata(&response)?.len() <= 65536,
|
||||
"Steam response too large"
|
||||
);
|
||||
return Ok(fs::read_to_string(&response)?);
|
||||
}
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"Steam did not acknowledge the Devkit request; keep Steam awake and retry"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
})();
|
||||
for p in [&response, &error, &lock] {
|
||||
let _ = fs::remove_file(p);
|
||||
}
|
||||
result
|
||||
}
|
||||
pub(super) fn linked(home: &Path, r: &Record) -> bool {
|
||||
r.steam_binding.as_ref().is_some_and(|b| {
|
||||
b.game_id == game_id(&r.id)
|
||||
&& if b.native {
|
||||
native_apk(home, &r.id).is_file()
|
||||
} else {
|
||||
wrapper(home, &r.id).is_file()
|
||||
}
|
||||
})
|
||||
}
|
||||
pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
ensure!(
|
||||
a.installed && a.state_known && !a.steam,
|
||||
"Only installed sideloaded APKs can use a Steam entry"
|
||||
);
|
||||
let token = r
|
||||
.steam_token
|
||||
.get_or_insert_with(|| hex::encode(rand::random::<[u8; 32]>()))
|
||||
.clone();
|
||||
let game = game_id(&a.id);
|
||||
let p = wrapper(home, &a.id);
|
||||
let dir = p.parent().unwrap();
|
||||
fs::create_dir_all(dir)?;
|
||||
ensure!(
|
||||
fs::symlink_metadata(dir)?.is_dir(),
|
||||
"Invalid Steam entry directory"
|
||||
);
|
||||
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
|
||||
let db = load(home)?;
|
||||
let (_, c) = app(home, &db, &a.id)?;
|
||||
let apk = installed(home, &c)?
|
||||
.into_iter()
|
||||
.find(|(m, _)| m.package == a.metadata.package)
|
||||
.map(|(_, p)| p)
|
||||
.context("Installed APK is unavailable; existing data was not changed")?;
|
||||
store_apk(&apk, dir)?;
|
||||
// A stable APK target lets Steam own the actual Lepton compatibility launch.
|
||||
for (suffix, value) in [
|
||||
("argv", json!(["./base.apk"])),
|
||||
("env", json!({"LEPTON_NO_CLEANUP":"true"})),
|
||||
(
|
||||
"settings",
|
||||
json!({"compat_tool":"fauxdroid","steam_play":"0"}),
|
||||
),
|
||||
] {
|
||||
write_owned(
|
||||
&home
|
||||
.join("devkit-game")
|
||||
.join(format!("{game}-{suffix}.json")),
|
||||
&serde_json::to_vec(&value)?,
|
||||
0o600,
|
||||
)?;
|
||||
}
|
||||
let marker = dir.join("lepton-show-flatscreen");
|
||||
if a.show_window.unwrap_or(!a.metadata.vr) {
|
||||
write_owned(&marker, b"", 0o600)?;
|
||||
} else if marker.exists() {
|
||||
fs::remove_file(&marker)?;
|
||||
}
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game.clone(),
|
||||
native: true,
|
||||
revision: revision(a),
|
||||
});
|
||||
rpc(home, "create-shortcut", Some(&game))?;
|
||||
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
|
||||
ensure!(
|
||||
v["version"] == 2
|
||||
&& v["gameids"]
|
||||
.as_array()
|
||||
.is_some_and(|v| v.iter().any(|v| v == &game)),
|
||||
"Steam did not register the APK entry"
|
||||
);
|
||||
let launch = format!(
|
||||
"/var/lib/framely/current/bin/framely apk-native --app {} --token {} -- %command%",
|
||||
quote(&a.id),
|
||||
quote(&token)
|
||||
);
|
||||
let icon = icon_file(dir, &a.metadata)?;
|
||||
r.steam_app_id = Some(super::steam_ui::configure(
|
||||
&game,
|
||||
r.steam_app_id,
|
||||
&a.metadata.name,
|
||||
icon.as_deref(),
|
||||
&launch,
|
||||
&native_apk(home, &a.id),
|
||||
)?);
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game,
|
||||
native: true,
|
||||
revision: revision(a),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
if let Some(b) = &r.steam_binding {
|
||||
ensure!(b.game_id == game_id(&r.id), "Invalid Steam APK binding");
|
||||
if b.native {
|
||||
if let Some(app_id) = r.steam_app_id {
|
||||
steam_ui::clear_artwork(app_id, &native_apk(home, &r.id))?;
|
||||
}
|
||||
}
|
||||
rpc(home, "delete-shortcut", Some(&b.game_id))?;
|
||||
// Remove only our fixed configuration files, so the Devkit sync cannot
|
||||
// re-register a disabled entry. Never recursively remove user content.
|
||||
for suffix in ["argv", "settings", "env"] {
|
||||
let p = home
|
||||
.join("devkit-game")
|
||||
.join(format!("{}-{suffix}.json", b.game_id));
|
||||
if p.exists() {
|
||||
fs::remove_file(p)?;
|
||||
}
|
||||
}
|
||||
let p = wrapper(home, &r.id);
|
||||
if p.exists() {
|
||||
fs::remove_file(&p)?;
|
||||
}
|
||||
for name in [
|
||||
"base.apk",
|
||||
"icon.png",
|
||||
"icon.webp",
|
||||
"icon.jpg",
|
||||
"lepton-show-flatscreen",
|
||||
] {
|
||||
let file = p.parent().unwrap().join(name);
|
||||
if file.exists() {
|
||||
ensure!(
|
||||
fs::symlink_metadata(&file)?.is_file(),
|
||||
"Invalid owned Steam file"
|
||||
);
|
||||
fs::remove_file(file)?;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir(p.parent().unwrap());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn revision(a: &App) -> String {
|
||||
hash(&json!({"metadata":a.metadata,"showWindow":a.show_window}).to_string())
|
||||
}
|
||||
fn icon_file(dir: &Path, metadata: &Metadata) -> Result<Option<PathBuf>> {
|
||||
use base64::Engine;
|
||||
let Some(icon) = metadata.icon.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some((prefix, data)) = icon.split_once(",") else {
|
||||
return Ok(None);
|
||||
};
|
||||
ensure!(
|
||||
matches!(
|
||||
prefix,
|
||||
"data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64"
|
||||
),
|
||||
"Unsupported APK icon"
|
||||
);
|
||||
ensure!(data.len() <= 8 * 1024 * 1024, "APK icon is too large");
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data)?;
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(64 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let picture = reader.decode()?.thumbnail(512, 512);
|
||||
let path = dir.join("icon.png");
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
picture.write_to(&mut png, image::ImageFormat::Png)?;
|
||||
write_owned(&path, &png.into_inner(), 0o600)?;
|
||||
Ok(Some(path))
|
||||
}
|
||||
// Preference and registration are separate: Steam can be asleep while the
|
||||
// default-on choice remains enabled. An explicit opt-out is never auto-enabled.
|
||||
pub(super) fn wanted(r: &Record) -> bool {
|
||||
r.steam_preference
|
||||
.unwrap_or(r.steam_launch || r.steam_token.is_none())
|
||||
}
|
||||
pub(super) fn ensure_registered(home: &Path, id: &str, force: bool) -> Result<bool> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
let directory = init(home)?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(directory.join("operation.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let mut db = load(home)?;
|
||||
if db.records.get(id).is_some_and(|r| !wanted(r)) {
|
||||
return Ok(false);
|
||||
}
|
||||
let (a, _) = app(home, &db, id)?;
|
||||
if a.steam {
|
||||
return Ok(false);
|
||||
}
|
||||
ensure!(
|
||||
a.installed && a.state_known && a.pending.is_none(),
|
||||
"Application state is unavailable for Steam registration"
|
||||
);
|
||||
ensure!(
|
||||
!a.metadata.activities.is_empty() || a.activity.is_some(),
|
||||
"No launch activity is available for Steam registration"
|
||||
);
|
||||
record_app(&mut db, &a);
|
||||
let retain = db.retain_steam_entries;
|
||||
let rec = db.records.get_mut(id).context("Missing APK record")?;
|
||||
if !retain && !context_busy(home, rec) {
|
||||
rec.steam_transient_generation = Some(hex::encode(rand::random::<[u8; 16]>()));
|
||||
rec.steam_transient_expires = now() + 180;
|
||||
}
|
||||
rec.steam_preference = Some(true);
|
||||
if !force
|
||||
&& rec.steam_launch
|
||||
&& linked(home, rec)
|
||||
&& rec
|
||||
.steam_binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a))
|
||||
{
|
||||
save(home, &db)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let result = register(home, &a, rec);
|
||||
rec.steam_launch = result.is_ok();
|
||||
rec.steam_registration_error = result.as_ref().err().map(|e| format!("{e:#}"));
|
||||
save(home, &db)?;
|
||||
result?;
|
||||
Ok(true)
|
||||
}
|
||||
static AUTO_WAKE: std::sync::OnceLock<std::sync::mpsc::SyncSender<()>> = std::sync::OnceLock::new();
|
||||
pub(super) fn wake_registration() {
|
||||
if let Some(sender) = AUTO_WAKE.get() {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
}
|
||||
pub(super) fn synchronize(home: &Path) -> Result<()> {
|
||||
// Query Steam once per pass, before inventory scanning; unavailable Steam
|
||||
// defers registration without blocking UI startup or starting any container.
|
||||
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
|
||||
ensure!(v["version"] == 2, "Unknown Steam Devkit registry");
|
||||
let registered: std::collections::BTreeSet<String> =
|
||||
serde_json::from_value(v["gameids"].clone())?;
|
||||
let db = load(home)?;
|
||||
let cs = containers(home, &db);
|
||||
let (inventory, _) = apps(home, &db, &cs, false);
|
||||
for a in inventory {
|
||||
if !a.installed
|
||||
|| !a.state_known
|
||||
|| a.steam
|
||||
|| a.pending.is_some()
|
||||
|| (a.metadata.activities.is_empty() && a.activity.is_none())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if db.records.get(&a.id).is_some_and(|r| !wanted(r)) {
|
||||
continue;
|
||||
}
|
||||
if !db.retain_steam_entries {
|
||||
let _ = cleanup_transient(home, &a.id, None);
|
||||
continue;
|
||||
}
|
||||
let force = !registered.contains(&game_id(&a.id));
|
||||
if !force
|
||||
&& db.records.get(&a.id).is_some_and(|r| {
|
||||
r.steam_preference == Some(true)
|
||||
&& r.steam_launch
|
||||
&& linked(home, r)
|
||||
&& r.steam_binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a))
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let _ = ensure_registered(home, &a.id, force);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn context_busy(home: &Path, r: &Record) -> bool {
|
||||
if running(&r.context) || native::runtime_name(r).is_some_and(|name| running(&name)) {
|
||||
return true;
|
||||
}
|
||||
let path = root(home)
|
||||
.join("steam")
|
||||
.join(format!("context-{}.lock", hash(&r.context)));
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
let Ok(file) = fs::OpenOptions::new().write(true).open(path) else {
|
||||
return true;
|
||||
};
|
||||
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) != 0 }
|
||||
}
|
||||
pub(super) fn cleanup_transient(home: &Path, id: &str, completed: Option<&str>) -> Result<()> {
|
||||
let _guard = MUTATION
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
let directory = init(home)?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(directory.join("operation.lock"))?;
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let mut db = load(home)?;
|
||||
if db.retain_steam_entries {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(r) = db.records.get(id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if r.steam_binding.is_none() || r.pending.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(generation) = completed {
|
||||
if r.steam_transient_generation.as_deref() != Some(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
} else if now() < r.steam_transient_expires {
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir_all(directory.join("steam"))?;
|
||||
let ownership = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(
|
||||
directory
|
||||
.join("steam")
|
||||
.join(format!("context-{}.lock", hash(&r.context))),
|
||||
)?;
|
||||
if unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0
|
||||
|| containers(home, &db)
|
||||
.iter()
|
||||
.any(|c| c.id == r.context && (c.running || wrapper_active(&c.name)))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
unregister(home, r)?;
|
||||
let r = db.records.get_mut(id).unwrap();
|
||||
r.steam_launch = false;
|
||||
r.steam_binding = None;
|
||||
r.steam_app_id = None;
|
||||
r.steam_registration_error = None;
|
||||
r.steam_transient_generation = None;
|
||||
r.steam_transient_expires = 0;
|
||||
save(home, &db)
|
||||
}
|
||||
pub(super) fn start_auto_registration() {
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
if AUTO_WAKE.set(tx).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
let _ = synchronize(&home);
|
||||
}
|
||||
let _ = rx.recv_timeout(Duration::from_secs(20));
|
||||
for _ in 0..64 {
|
||||
if rx.try_recv().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn preferences_migrate_default_on_and_preserve_legacy_explicit_disables() {
|
||||
let mut r = Record::default();
|
||||
assert!(wanted(&r));
|
||||
let mut value = serde_json::to_value(&r).unwrap();
|
||||
value.as_object_mut().unwrap().remove("steamPreference");
|
||||
assert!(wanted(&serde_json::from_value::<Record>(value).unwrap()));
|
||||
r.steam_token = Some("legacy".into());
|
||||
assert!(!wanted(&r));
|
||||
r.steam_launch = true;
|
||||
assert!(wanted(&r));
|
||||
r.steam_preference = Some(false);
|
||||
assert!(!wanted(&r));
|
||||
r.steam_preference = Some(true);
|
||||
r.steam_launch = false;
|
||||
assert!(wanted(&r));
|
||||
}
|
||||
#[test]
|
||||
fn devkit_ids_are_stable_safe_and_unique() {
|
||||
let a = game_id("ctx/com.test.app");
|
||||
assert_eq!(a, game_id("ctx/com.test.app"));
|
||||
assert_ne!(a, game_id("other/com.test.app"));
|
||||
assert!(a.bytes().all(|b| b.is_ascii_alphanumeric()));
|
||||
}
|
||||
#[test]
|
||||
fn owned_entry_rejects_symlinks() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let target = t.path().join("data");
|
||||
fs::write(&target, b"preserve").unwrap();
|
||||
let p = t.path().join("launch.sh");
|
||||
std::os::unix::fs::symlink(&target, &p).unwrap();
|
||||
assert!(write_owned(&p, b"replace", 0o700).is_err());
|
||||
assert_eq!(fs::read(target).unwrap(), b"preserve");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Local Steam UI API: metadata and launch options, never edits live VDF files.
|
||||
use super::*;
|
||||
use std::{
|
||||
io::Read,
|
||||
net::{SocketAddr, TcpStream},
|
||||
};
|
||||
use tungstenite::{client, protocol::WebSocketConfig, Message};
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {pub(super) static TEST_RESULT: std::cell::RefCell<Option<Value>>= const {std::cell::RefCell::new(None)};}
|
||||
pub(crate) fn evaluate(expression: &str) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
if let Some(value) = TEST_RESULT.with(|v| v.borrow().clone()) {
|
||||
return Ok(value);
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build();
|
||||
let mut bytes = Vec::new();
|
||||
agent
|
||||
.get("http://127.0.0.1:8080/json")
|
||||
.call()
|
||||
.context("Steam UI connection is unavailable; keep Steam awake and retry")?
|
||||
.into_reader()
|
||||
.take(1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 1024 * 1024,
|
||||
"Steam UI discovery response is too large"
|
||||
);
|
||||
let pages: Vec<Value> = serde_json::from_slice(&bytes)?;
|
||||
let page = pages
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p["title"] == "SharedJSContext"
|
||||
&& p["url"]
|
||||
.as_str()
|
||||
.is_some_and(|u| u.starts_with("https://steamloopback.host/"))
|
||||
})
|
||||
.context("Steam library UI is not ready; keep Steam awake and retry")?;
|
||||
let address = url::Url::parse(
|
||||
page["webSocketDebuggerUrl"]
|
||||
.as_str()
|
||||
.context("Missing Steam UI endpoint")?,
|
||||
)?;
|
||||
ensure!(
|
||||
address.scheme() == "ws"
|
||||
&& address.host_str() == Some("127.0.0.1")
|
||||
&& address.port() == Some(8080)
|
||||
&& address.path().starts_with("/devtools/page/"),
|
||||
"Invalid Steam UI endpoint"
|
||||
);
|
||||
let stream = TcpStream::connect_timeout(
|
||||
&SocketAddr::from(([127, 0, 0, 1], 8080)),
|
||||
Duration::from_secs(3),
|
||||
)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
|
||||
let (mut ws, _) = client(address.as_str(), stream)
|
||||
.map_err(|_| anyhow::anyhow!("Cannot connect to Steam UI"))?;
|
||||
ws.set_config(|c| {
|
||||
*c = WebSocketConfig::default()
|
||||
.max_message_size(Some(4 * 1024 * 1024))
|
||||
.max_frame_size(Some(4 * 1024 * 1024));
|
||||
});
|
||||
ws.send(Message::Text(
|
||||
json!({"id":1,"method":"Runtime.evaluate","params":{
|
||||
"expression":expression,"returnByValue":true,"awaitPromise":true
|
||||
}})
|
||||
.to_string()
|
||||
.into(),
|
||||
))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
ensure!(Instant::now() < deadline, "Steam UI request timed out");
|
||||
let message = ws
|
||||
.read()
|
||||
.map_err(|_| anyhow::anyhow!("Steam UI request failed"))?;
|
||||
if let Message::Text(text) = message {
|
||||
let v: Value = serde_json::from_str(&text)?;
|
||||
if v["id"] != 1 {
|
||||
continue;
|
||||
}
|
||||
// Do not return JavaScript exception descriptions: they can contain launch tokens.
|
||||
ensure!(
|
||||
v.get("error").is_none() && v["result"].get("exceptionDetails").is_none(),
|
||||
"Steam UI operation failed"
|
||||
);
|
||||
return Ok(v["result"]["result"]["value"].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn configure(
|
||||
game: &str,
|
||||
previous: Option<u32>,
|
||||
name: &str,
|
||||
icon: Option<&Path>,
|
||||
launch: &str,
|
||||
executable: &Path,
|
||||
) -> Result<u32> {
|
||||
let art = artwork(icon)?;
|
||||
let parameters = json!({"game":game,"previous":previous,"name":name,
|
||||
"icon":icon,"launch":launch,"exe":executable,"art":art});
|
||||
let expression = format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
let app;
|
||||
for(let n=0;n<10;n++){{
|
||||
const apps=Array.from(appStore.m_mapApps.values());
|
||||
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
|
||||
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
|
||||
if(app) break;
|
||||
await new Promise(r=>setTimeout(r,100));
|
||||
}}
|
||||
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
|
||||
const id=app.appid;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
|
||||
SteamClient.Apps.SetShortcutName(id,p.name);
|
||||
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
for(const a of p.art){{
|
||||
try{{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind)}}
|
||||
catch(e){{
|
||||
await new Promise(r=>setTimeout(r,200));
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);
|
||||
}}
|
||||
}}
|
||||
return id;
|
||||
}})()"#
|
||||
);
|
||||
let result = evaluate(&expression)?;
|
||||
result
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v >= 0x80000000)
|
||||
.context("Invalid Steam APK App ID")
|
||||
}
|
||||
|
||||
fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
use base64::Engine;
|
||||
let Some(path) = icon else { return Ok(vec![]) };
|
||||
let image = image::open(path)?.to_rgba8();
|
||||
let mut out = Vec::new();
|
||||
for (kind, w, h) in [(0, 600, 900), (1, 1280, 720), (3, 920, 430), (4, 512, 512)] {
|
||||
let mut canvas = image::RgbaImage::from_pixel(w, h, image::Rgba([22, 24, 27, 255]));
|
||||
let size = if kind == 4 {
|
||||
512
|
||||
} else {
|
||||
(w.min(h) * 3 / 5).min(360)
|
||||
};
|
||||
let icon = image::imageops::thumbnail(&image, size, size);
|
||||
image::imageops::overlay(
|
||||
&mut canvas,
|
||||
&icon,
|
||||
((w - icon.width()) / 2) as i64,
|
||||
((h - icon.height()) / 2) as i64,
|
||||
);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(canvas).write_to(&mut bytes, image::ImageFormat::Png)?;
|
||||
out.push(json!({"kind":kind,"data":base64::engine::general_purpose::STANDARD.encode(bytes.into_inner())}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
ensure!(app_id >= 0x80000000, "Invalid owned Steam shortcut ID");
|
||||
let parameters = json!({"id":app_id,"exe":executable});
|
||||
evaluate(&format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
const app=appStore.m_mapApps.get(p.id);
|
||||
if(!app) return;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
const timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(p.id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
for(const kind of [0,1,3,4]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
await SteamClient.Apps.SetShortcutIcon(p.id,'');
|
||||
}})()"#
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn steam_artwork_has_matching_aspect_ratios_and_never_crops_icon() {
|
||||
use base64::Engine;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let p = t.path().join("icon.png");
|
||||
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
|
||||
.save(&p)
|
||||
.unwrap();
|
||||
let art = artwork(Some(&p)).unwrap();
|
||||
assert_eq!(art.len(), 4);
|
||||
for (a, expected) in art
|
||||
.iter()
|
||||
.zip([(600, 900), (1280, 720), (920, 430), (512, 512)])
|
||||
{
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(a["data"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let i = image::load_from_memory(&b).unwrap().to_rgba8();
|
||||
assert_eq!(i.dimensions(), expected);
|
||||
assert_eq!(
|
||||
i.get_pixel(expected.0 / 2, expected.1 / 2).0,
|
||||
[230, 30, 20, 255]
|
||||
);
|
||||
}
|
||||
assert!(artwork(None).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Resource and Mesa cache directories remain inside the backed-up context,
|
||||
//! but outside data_overlay so downloads do not pay OverlayFS write costs.
|
||||
use anyhow::{ensure, Result};
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
pub(super) fn prepare(baked: &Path) -> Result<()> {
|
||||
ensure!(baked.is_absolute(), "Container data path must be absolute");
|
||||
ensure!(
|
||||
!baked.is_symlink(),
|
||||
"Container data directory cannot be a link"
|
||||
);
|
||||
let cache = baked.join("shadercache");
|
||||
ensure!(
|
||||
!cache.is_symlink(),
|
||||
"Shader cache directory cannot be a link"
|
||||
);
|
||||
fs::create_dir_all(cache)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn configure(command: &mut Command, baked: &Path, adopted: bool) {
|
||||
// An inherited Steam compat path can make Lepton relocate a development
|
||||
// context. Only adopted Steam contexts should receive that variable.
|
||||
command
|
||||
.env_remove("FRAMELY_EXTERNAL_MEDIA_DIR")
|
||||
.env_remove("STEAM_COMPAT_SHADER_PATH")
|
||||
.env("FRAMELY_SHADER_CACHE_DIR", baked.join("shadercache"));
|
||||
if !adopted {
|
||||
command
|
||||
.env_remove("STEAM_COMPAT_DATA_PATH")
|
||||
.env("FRAMELY_EXTERNAL_MEDIA_DIR", baked.join("external"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const HOOKS: &str = r#"
|
||||
function framely_prepare_external_media() {
|
||||
local media="$(data_mount_path)/media/0"
|
||||
local external="${FRAMELY_EXTERNAL_MEDIA_DIR:?}"
|
||||
[[ "$external" == /* && "$external" != "$media" && ! -L "$external" ]] || {
|
||||
echo "Invalid Android external storage directory" >&2; return 64;
|
||||
}
|
||||
if [[ -e "$external" && ! -d "$external" ]]; then
|
||||
echo "Android external storage is not a directory" >&2; return 64
|
||||
fi
|
||||
if [[ -L "$media" ]]; then
|
||||
[[ "$(readlink "$media")" == "$external" && -d "$external" ]] || {
|
||||
echo "Unexpected Android media link; refusing to replace it" >&2; return 64;
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
if [[ -d "$media" ]]; then
|
||||
if [[ -z "$(find "$media" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||
rmdir -- "$media" || return
|
||||
else
|
||||
if [[ -d "$external" ]]; then
|
||||
[[ -z "$(find "$external" -mindepth 1 -maxdepth 1 -print -quit)" ]] || {
|
||||
echo "Both Android media locations contain data; refusing to discard either" >&2; return 64;
|
||||
}
|
||||
rmdir -- "$external" || return
|
||||
fi
|
||||
# Both paths belong to the same context filesystem. Rename keeps
|
||||
# partial downloads, checkpoints and file ownership intact.
|
||||
mv -T -- "$media" "$external" || return
|
||||
fi
|
||||
elif [[ -e "$media" ]]; then
|
||||
echo "Invalid Android media location" >&2; return 64
|
||||
fi
|
||||
mkdir -p -- "$external" "$(dirname "$media")" || return
|
||||
ln -s -- "$external" "$media"
|
||||
}
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::{fs::symlink, fs::MetadataExt};
|
||||
|
||||
fn migrate(data: &Path, external: &Path) -> bool {
|
||||
Command::new("bash")
|
||||
.args(["-euc", &format!("function data_mount_path() {{ echo \"$TEST_DATA\"; }}\n{HOOKS}\nframely_prepare_external_media")])
|
||||
.env("TEST_DATA", data)
|
||||
.env("FRAMELY_EXTERNAL_MEDIA_DIR", external)
|
||||
.env("BASH_ENV", "/dev/null")
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_migration_preserves_inodes_and_resumes_after_interruption() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("data_overlay");
|
||||
let external = dir.path().join("external");
|
||||
let resource = Path::new("Android/data/game/files/resource.part");
|
||||
let original = data.join("media/0").join(resource);
|
||||
fs::create_dir_all(original.parent().unwrap()).unwrap();
|
||||
fs::write(&original, b"unfinished download").unwrap();
|
||||
fs::write(original.with_file_name("checkpoint"), b"offset=19").unwrap();
|
||||
let inode = fs::metadata(&original).unwrap().ino();
|
||||
assert!(migrate(&data, &external));
|
||||
assert_eq!(fs::metadata(external.join(resource)).unwrap().ino(), inode);
|
||||
assert!(migrate(&data, &external));
|
||||
assert_eq!(fs::read(&original).unwrap(), b"unfinished download");
|
||||
assert_eq!(
|
||||
fs::read(original.with_file_name("checkpoint")).unwrap(),
|
||||
b"offset=19"
|
||||
);
|
||||
// Power loss after rename but before symlink creation is recoverable.
|
||||
fs::remove_file(data.join("media/0")).unwrap();
|
||||
assert!(migrate(&data, &external));
|
||||
assert_eq!(fs::metadata(&original).unwrap().ino(), inode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_refuses_conflicting_data_and_unexpected_links() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data = dir.path().join("data_overlay");
|
||||
let media = data.join("media/0");
|
||||
let external = dir.path().join("external");
|
||||
fs::create_dir_all(&media).unwrap();
|
||||
fs::create_dir(&external).unwrap();
|
||||
fs::write(media.join("resource"), b"original").unwrap();
|
||||
fs::write(external.join("resource"), b"other").unwrap();
|
||||
assert!(!migrate(&data, &external));
|
||||
assert_eq!(fs::read(media.join("resource")).unwrap(), b"original");
|
||||
assert_eq!(fs::read(external.join("resource")).unwrap(), b"other");
|
||||
fs::remove_dir_all(&media).unwrap();
|
||||
symlink(dir.path(), &media).unwrap();
|
||||
assert!(!migrate(&data, &external));
|
||||
assert_eq!(fs::read_link(&media).unwrap(), dir.path());
|
||||
fs::remove_file(&media).unwrap();
|
||||
fs::remove_dir_all(&external).unwrap();
|
||||
symlink(dir.path(), &external).unwrap();
|
||||
assert!(!migrate(&data, &external));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_is_persistent_and_adopted_contexts_keep_their_compat_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baked = dir.path().join("baked");
|
||||
prepare(&baked).unwrap();
|
||||
let cache = baked.join("shadercache/compiled");
|
||||
fs::write(&cache, b"shader").unwrap();
|
||||
prepare(&baked).unwrap();
|
||||
assert_eq!(fs::read(&cache).unwrap(), b"shader");
|
||||
for adopted in [false, true] {
|
||||
let mut command = Command::new("true");
|
||||
command.env("STEAM_COMPAT_DATA_PATH", "/steam/compat");
|
||||
configure(&mut command, &baked, adopted);
|
||||
let env: std::collections::BTreeMap<_, _> = command.get_envs().collect();
|
||||
assert_eq!(
|
||||
env[std::ffi::OsStr::new("STEAM_COMPAT_DATA_PATH")],
|
||||
adopted.then_some(std::ffi::OsStr::new("/steam/compat"))
|
||||
);
|
||||
assert_eq!(
|
||||
env[std::ffi::OsStr::new("FRAMELY_EXTERNAL_MEDIA_DIR")],
|
||||
(!adopted).then_some(baked.join("external").as_os_str())
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(baked.join("shadercache")).unwrap();
|
||||
symlink(dir.path(), baked.join("shadercache")).unwrap();
|
||||
assert!(prepare(&baked).is_err());
|
||||
}
|
||||
}
|
||||
+416
-28
@@ -55,26 +55,94 @@ fn string_len(b: &[u8], p: &mut usize, utf8: bool) -> Result<usize> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Android resource pools can encode supplementary characters as two UTF-8
|
||||
// surrogate code units (CESU-8), rather than one standard four-byte sequence.
|
||||
// Decode only those extensions; malformed UTF-8 and unpaired surrogates remain
|
||||
// errors instead of silently replacing package metadata.
|
||||
fn android_utf8(b: &[u8]) -> Result<String> {
|
||||
if let Ok(s) = std::str::from_utf8(b) {
|
||||
return Ok(s.to_owned());
|
||||
}
|
||||
let mut units = Vec::new();
|
||||
let mut remaining = b;
|
||||
while !remaining.is_empty() {
|
||||
match std::str::from_utf8(remaining) {
|
||||
Ok(s) => {
|
||||
units.extend(s.encode_utf16());
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let valid = e.valid_up_to();
|
||||
units.extend(std::str::from_utf8(&remaining[..valid])?.encode_utf16());
|
||||
remaining = &remaining[valid..];
|
||||
if remaining.starts_with(&[0xc0, 0x80]) {
|
||||
// Modified UTF-8's encoding of NUL.
|
||||
units.push(0);
|
||||
remaining = &remaining[2..];
|
||||
} else if remaining.len() >= 3
|
||||
&& remaining[0] == 0xed
|
||||
&& (0xa0..=0xbf).contains(&remaining[1])
|
||||
&& (0x80..=0xbf).contains(&remaining[2])
|
||||
{
|
||||
units.push(
|
||||
((remaining[0] as u16 & 15) << 12)
|
||||
| ((remaining[1] as u16 & 63) << 6)
|
||||
| (remaining[2] as u16 & 63),
|
||||
);
|
||||
remaining = &remaining[3..];
|
||||
} else {
|
||||
bail!("Invalid Android resource string encoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf16(&units).context("Unpaired surrogate in Android resource string")
|
||||
}
|
||||
fn strings(b: &[u8]) -> Result<Vec<String>> {
|
||||
let count = u32at(b, 8)? as usize;
|
||||
ensure!(count <= 262144, "APK string pool is too large");
|
||||
let utf8 = u32at(b, 16)? & 256 != 0;
|
||||
let start = u32at(b, 20)? as usize;
|
||||
let header = u16at(b, 2)? as usize;
|
||||
let styles = u32at(b, 12)? as usize;
|
||||
let style_start = u32at(b, 24)? as usize;
|
||||
ensure!(
|
||||
header >= 28 && start >= header + (count + styles) * 4 && start <= b.len(),
|
||||
"Invalid APK string pool layout"
|
||||
);
|
||||
let end = if style_start == 0 {
|
||||
b.len()
|
||||
} else {
|
||||
style_start
|
||||
};
|
||||
ensure!(
|
||||
end >= start && end <= b.len(),
|
||||
"Invalid APK string data bounds"
|
||||
);
|
||||
let data = &b[..end];
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let mut p = start
|
||||
.checked_add(u32at(b, header + i * 4)? as usize)
|
||||
.context("Invalid string offset")?;
|
||||
let n = string_len(b, &mut p, utf8)?;
|
||||
let n = string_len(data, &mut p, utf8)?;
|
||||
if utf8 {
|
||||
let bytes = string_len(b, &mut p, true)?;
|
||||
Ok(
|
||||
std::str::from_utf8(b.get(p..p + bytes).context("Invalid APK string")?)?
|
||||
.to_owned(),
|
||||
)
|
||||
let bytes = string_len(data, &mut p, true)?;
|
||||
let s = android_utf8(data.get(p..p + bytes).context("Invalid APK string")?)
|
||||
.with_context(|| format!("Invalid APK string pool entry {i}"))?;
|
||||
ensure!(data.get(p + bytes) == Some(&0), "Unterminated APK string");
|
||||
// Older AAPT versions truncate encoded lengths to 15 bits.
|
||||
ensure!(
|
||||
s.encode_utf16().count() & 0x7fff == n,
|
||||
"Invalid APK string length"
|
||||
);
|
||||
Ok(s)
|
||||
} else {
|
||||
let bytes = b.get(p..p + n * 2).context("Invalid UTF16 string")?;
|
||||
let bytes = data.get(p..p + n * 2).context("Invalid UTF16 string")?;
|
||||
ensure!(
|
||||
u16at(data, p + n * 2)? == 0,
|
||||
"Unterminated UTF16 APK string"
|
||||
);
|
||||
Ok(String::from_utf16(
|
||||
&bytes
|
||||
.chunks_exact(2)
|
||||
@@ -104,6 +172,9 @@ enum Val {
|
||||
fn val(b: &[u8], p: usize, s: &[String]) -> Result<Val> {
|
||||
let typ = *b.get(p + 3).context("Invalid value")?;
|
||||
let v = u32at(b, p + 4)?;
|
||||
value(typ, v, s)
|
||||
}
|
||||
fn value(typ: u8, v: u32, s: &[String]) -> Result<Val> {
|
||||
Ok(match typ {
|
||||
3 => Val::Text(
|
||||
s.get(v as usize)
|
||||
@@ -114,8 +185,37 @@ fn val(b: &[u8], p: usize, s: &[String]) -> Result<Val> {
|
||||
_ => Val::Int(v),
|
||||
})
|
||||
}
|
||||
fn icon_mime(path: &str) -> Option<&'static str> {
|
||||
if path.ends_with(".png") {
|
||||
Some("image/png")
|
||||
} else if path.ends_with(".webp") {
|
||||
Some("image/webp")
|
||||
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
|
||||
Some("image/jpeg")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn resource_image_rank(value: &Val, density: u16) -> Option<(bool, u16)> {
|
||||
let raster = match value {
|
||||
Val::Text(path) if path.starts_with("res/") => icon_mime(path).is_some(),
|
||||
Val::Ref(_) => false,
|
||||
_ => return None,
|
||||
};
|
||||
// Any-dpi XML is not a bitmap. Prefer a full raster launcher icon over
|
||||
// unsupported adaptive/vector XML, then the highest packaged density.
|
||||
Some((
|
||||
raster,
|
||||
match density {
|
||||
0 => 160,
|
||||
0xfffe | 0xffff => 0,
|
||||
d => d,
|
||||
},
|
||||
))
|
||||
}
|
||||
fn resources(b: &[u8]) -> Result<BTreeMap<u32, Val>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut image_ranks = BTreeMap::new();
|
||||
let mut pool = Vec::new();
|
||||
for c in chunks(b, u16at(b, 2)? as usize)? {
|
||||
match u16at(c, 0)? {
|
||||
@@ -126,28 +226,82 @@ fn resources(b: &[u8]) -> Result<BTreeMap<u32, Val>> {
|
||||
if u16at(t, 0)? != 0x201 {
|
||||
continue;
|
||||
}
|
||||
let id = *t.get(8).context("Invalid resource type")? as u32;
|
||||
let flags = t[9];
|
||||
if flags != 0 {
|
||||
let type_offset = if u16at(c, 2)? >= 288 {
|
||||
u32at(c, 284)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let id = (*t.get(8).context("Invalid resource type")? as u32)
|
||||
.checked_add(type_offset)
|
||||
.context("Invalid APK resource type offset")?;
|
||||
ensure!(id > 0 && id <= 255, "Invalid APK resource type ID");
|
||||
let flags = *t.get(9).context("Invalid resource type flags")?;
|
||||
if flags & !3 != 0 {
|
||||
continue;
|
||||
}
|
||||
ensure!(flags != 3, "Conflicting APK resource offset formats");
|
||||
let count = u32at(t, 12)? as usize;
|
||||
let start = u32at(t, 16)? as usize;
|
||||
let h = u16at(t, 2)? as usize;
|
||||
// ResTable_config starts at byte 20; density is byte 14.
|
||||
let density = if h >= 36 && u32at(t, 20)? >= 16 {
|
||||
u16at(t, 34)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
ensure!(count <= 65536, "Too many APK resources");
|
||||
let stride = if flags == 2 { 2 } else { 4 };
|
||||
ensure!(
|
||||
h >= 20 && start >= h + count * stride && start <= t.len(),
|
||||
"Invalid APK resource entry table"
|
||||
);
|
||||
for i in 0..count {
|
||||
let offset = u32at(t, h + i * 4)?;
|
||||
if offset == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let (index, offset) = match flags {
|
||||
1 => (
|
||||
u16at(t, h + i * 4)? as u32,
|
||||
u16at(t, h + i * 4 + 2)? as usize * 4,
|
||||
),
|
||||
2 => {
|
||||
let offset = u16at(t, h + i * 2)?;
|
||||
if offset == u16::MAX {
|
||||
continue;
|
||||
}
|
||||
(i as u32, offset as usize * 4)
|
||||
}
|
||||
_ => {
|
||||
let offset = u32at(t, h + i * 4)?;
|
||||
if offset == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
(i as u32, offset as usize)
|
||||
}
|
||||
};
|
||||
let e = start + offset as usize;
|
||||
let size = u16at(t, e)? as usize;
|
||||
if u16at(t, e + 2)? & 1 != 0 {
|
||||
let entry_flags = u16at(t, e + 2)?;
|
||||
if entry_flags & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let key = (pkg << 24) | (id << 16) | i as u32;
|
||||
let v = val(t, e + size, &pool)?;
|
||||
map.entry(key).or_insert(v);
|
||||
let key = (pkg << 24) | (id << 16) | index;
|
||||
let v = if entry_flags & 8 != 0 {
|
||||
value((entry_flags >> 8) as u8, u32at(t, e + 4)?, &pool)?
|
||||
} else {
|
||||
ensure!(size >= 8, "Invalid APK resource entry size");
|
||||
val(t, e + size, &pool)?
|
||||
};
|
||||
if let Some(rank) = resource_image_rank(&v, density) {
|
||||
if image_ranks
|
||||
.get(&key)
|
||||
.is_none_or(|previous| rank > *previous)
|
||||
{
|
||||
image_ranks.insert(key, rank);
|
||||
map.insert(key, v);
|
||||
}
|
||||
} else {
|
||||
// Labels and other text retain their existing fallback;
|
||||
// selecting a larger icon must not change their language.
|
||||
map.entry(key).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,7 +486,7 @@ pub fn read(path: &Path) -> Result<Metadata> {
|
||||
let mut z = zip::ZipArchive::new(File::open(path)?).context("Invalid APK archive")?;
|
||||
ensure!(z.len() <= 65536, "Too many APK files");
|
||||
let r = match entry(&mut z, "resources.arsc", 64 * 1024 * 1024) {
|
||||
Ok(b) => resources(&b)?,
|
||||
Ok(b) => resources(&b).context("Cannot parse APK resource table")?,
|
||||
Err(e) => {
|
||||
if z.by_name("resources.arsc").is_ok() {
|
||||
return Err(e);
|
||||
@@ -341,15 +495,9 @@ pub fn read(path: &Path) -> Result<Metadata> {
|
||||
}
|
||||
};
|
||||
let b = entry(&mut z, "AndroidManifest.xml", 8 * 1024 * 1024)?;
|
||||
let mut m = xml(&b, &r)?;
|
||||
let mut m = xml(&b, &r).context("Cannot parse APK manifest")?;
|
||||
if let Some(path) = m.icon.take() {
|
||||
let mime = if path.ends_with(".png") {
|
||||
Some("image/png")
|
||||
} else if path.ends_with(".webp") {
|
||||
Some("image/webp")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mime = icon_mime(&path);
|
||||
if let Some(mime) = mime {
|
||||
if let Ok(b) = entry(&mut z, &path, 2 * 1024 * 1024) {
|
||||
m.icon = Some(format!("data:{mime};base64,{}", STANDARD.encode(b)));
|
||||
@@ -379,6 +527,242 @@ pub fn read(path: &Path) -> Result<Metadata> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn utf8_pool(entries: &[(u8, &[u8])]) -> Vec<u8> {
|
||||
let start = 28 + entries.len() * 4;
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for (units, bytes) in entries {
|
||||
offsets.push(data.len() as u32);
|
||||
data.extend([*units, bytes.len() as u8]);
|
||||
data.extend(*bytes);
|
||||
data.push(0);
|
||||
}
|
||||
let mut pool = Vec::new();
|
||||
pool.extend(1u16.to_le_bytes());
|
||||
pool.extend(28u16.to_le_bytes());
|
||||
pool.extend(((start + data.len()) as u32).to_le_bytes());
|
||||
pool.extend((entries.len() as u32).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
pool.extend(256u32.to_le_bytes());
|
||||
pool.extend((start as u32).to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
for offset in offsets {
|
||||
pool.extend(offset.to_le_bytes());
|
||||
}
|
||||
pool.extend(data);
|
||||
pool
|
||||
}
|
||||
#[test]
|
||||
fn android_resource_strings_accept_cesu8_and_standard_utf8() {
|
||||
// Moonlight V+ resources start with CESU-8 emoji, e.g. "🌐 Bitrate".
|
||||
let pool = utf8_pool(&[
|
||||
(10, b"\xed\xa0\xbc\xed\xbc\x90 Bitrate"),
|
||||
(5, "中文 🌸".as_bytes()),
|
||||
(3, b"a\xc0\x80b"),
|
||||
]);
|
||||
assert_eq!(strings(&pool).unwrap(), ["🌐 Bitrate", "中文 🌸", "a\0b"]);
|
||||
for invalid in [
|
||||
&b"\xed\xa0\xbc"[..], // Unpaired high surrogate.
|
||||
&b"\xed\xbc\x90"[..], // Unpaired low surrogate.
|
||||
&b"\xed\xa0"[..],
|
||||
&b"\xff"[..],
|
||||
&b"\xc0\xaf"[..], // Overlong encoding must not be accepted.
|
||||
&b"\xf4\x90\x80\x80"[..],
|
||||
] {
|
||||
assert!(strings(&utf8_pool(&[(1, invalid)])).is_err());
|
||||
}
|
||||
assert!(strings(&utf8_pool(&[(1, b"two")])).is_err());
|
||||
let mut unterminated = utf8_pool(&[(1, b"a")]);
|
||||
*unterminated.last_mut().unwrap() = 1;
|
||||
assert!(strings(&unterminated).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn utf16_resource_strings_and_string_bounds() {
|
||||
let units: Vec<u16> = "中文 🌸".encode_utf16().collect();
|
||||
let mut pool = utf8_pool(&[]);
|
||||
pool[8..12].copy_from_slice(&1u32.to_le_bytes());
|
||||
pool[16..20].copy_from_slice(&0u32.to_le_bytes());
|
||||
pool[20..24].copy_from_slice(&32u32.to_le_bytes());
|
||||
pool.extend(0u32.to_le_bytes());
|
||||
pool.extend((units.len() as u16).to_le_bytes());
|
||||
for unit in units {
|
||||
pool.extend(unit.to_le_bytes());
|
||||
}
|
||||
pool.extend(0u16.to_le_bytes());
|
||||
let len = pool.len() as u32;
|
||||
pool[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert_eq!(strings(&pool).unwrap(), ["中文 🌸"]);
|
||||
pool.pop();
|
||||
assert!(strings(&pool).is_err());
|
||||
let mut pool = utf8_pool(&[(1, b"a")]);
|
||||
pool[20..24].copy_from_slice(&0u32.to_le_bytes());
|
||||
assert!(strings(&pool).is_err());
|
||||
let mut pool = utf8_pool(&[(1, b"a")]);
|
||||
pool[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(strings(&pool).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn launcher_icon_uses_high_density_raster_independent_of_table_order() {
|
||||
let names = [
|
||||
"res/mipmap-mdpi/icon.png",
|
||||
"res/mipmap-xxxhdpi/icon.png",
|
||||
"res/mipmap-xhdpi/icon.png",
|
||||
"res/mipmap-anydpi-v26/icon.xml",
|
||||
];
|
||||
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
|
||||
let mut package = vec![0; 288];
|
||||
package[0..2].copy_from_slice(&0x200u16.to_le_bytes());
|
||||
package[2..4].copy_from_slice(&288u16.to_le_bytes());
|
||||
package[8..12].copy_from_slice(&127u32.to_le_bytes());
|
||||
for i in order {
|
||||
let mut table = vec![0; 40];
|
||||
table[0..2].copy_from_slice(&0x201u16.to_le_bytes());
|
||||
table[2..4].copy_from_slice(&40u16.to_le_bytes());
|
||||
table[8] = 1;
|
||||
table[12..16].copy_from_slice(&1u32.to_le_bytes());
|
||||
table[16..20].copy_from_slice(&44u32.to_le_bytes());
|
||||
table[20..24].copy_from_slice(&20u32.to_le_bytes());
|
||||
table[34..36].copy_from_slice(&[160u16, 640, 320, 0xfffe][i].to_le_bytes());
|
||||
table.extend(0u32.to_le_bytes());
|
||||
table.extend(8u16.to_le_bytes());
|
||||
table.extend(0u16.to_le_bytes());
|
||||
table.extend(0u32.to_le_bytes());
|
||||
table.extend([8, 0, 0, 3]);
|
||||
table.extend((i as u32).to_le_bytes());
|
||||
let len = table.len() as u32;
|
||||
table[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
package.extend(table);
|
||||
}
|
||||
let len = package.len() as u32;
|
||||
package[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let mut resource = vec![0; 12];
|
||||
resource[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
resource[2..4].copy_from_slice(&12u16.to_le_bytes());
|
||||
resource.extend(utf8_pool(
|
||||
&names
|
||||
.iter()
|
||||
.map(|n| (n.len() as u8, n.as_bytes()))
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
resource.extend(package);
|
||||
let len = resource.len() as u32;
|
||||
resource[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let parsed = resources(&resource).unwrap();
|
||||
assert_eq!(text(parsed.get(&0x7f010000), &parsed), names[1]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn resource_offsets_and_compact_entries() {
|
||||
for flags in [0u8, 1, 2] {
|
||||
for compact in [false, true] {
|
||||
let mut table = vec![0; 20];
|
||||
table[0..2].copy_from_slice(&0x201u16.to_le_bytes());
|
||||
table[2..4].copy_from_slice(&20u16.to_le_bytes());
|
||||
table[8] = 1;
|
||||
table[9] = flags;
|
||||
let count: u32 = if flags == 1 { 1 } else { 6 };
|
||||
table[12..16].copy_from_slice(&count.to_le_bytes());
|
||||
match flags {
|
||||
1 => {
|
||||
table.extend(5u16.to_le_bytes());
|
||||
table.extend(0u16.to_le_bytes());
|
||||
}
|
||||
2 => {
|
||||
for _ in 0..5 {
|
||||
table.extend(u16::MAX.to_le_bytes());
|
||||
}
|
||||
table.extend(0u16.to_le_bytes());
|
||||
}
|
||||
_ => {
|
||||
for _ in 0..5 {
|
||||
table.extend(u32::MAX.to_le_bytes());
|
||||
}
|
||||
table.extend(0u32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let start = table.len() as u32;
|
||||
table[16..20].copy_from_slice(&start.to_le_bytes());
|
||||
table.extend((if compact { 123u16 } else { 8 }).to_le_bytes());
|
||||
table.extend((if compact { 0x308u16 } else { 0 }).to_le_bytes());
|
||||
table.extend(0u32.to_le_bytes());
|
||||
if !compact {
|
||||
table.extend([8, 0, 0, 3]);
|
||||
table.extend(0u32.to_le_bytes());
|
||||
}
|
||||
let len = table.len() as u32;
|
||||
table[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let mut package = vec![0; 288];
|
||||
package[0..2].copy_from_slice(&0x200u16.to_le_bytes());
|
||||
package[2..4].copy_from_slice(&288u16.to_le_bytes());
|
||||
package[8..12].copy_from_slice(&127u32.to_le_bytes());
|
||||
package[284..288].copy_from_slice(&2u32.to_le_bytes());
|
||||
package.extend(table);
|
||||
let len = package.len() as u32;
|
||||
package[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let mut resource = vec![0; 12];
|
||||
resource[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
resource[2..4].copy_from_slice(&12u16.to_le_bytes());
|
||||
resource.extend(utf8_pool(&[(5, b"Label")]));
|
||||
resource.extend(package);
|
||||
let len = resource.len() as u32;
|
||||
resource[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let parsed = resources(&resource).unwrap();
|
||||
assert_eq!(text(parsed.get(&0x7f030005), &parsed), "Label");
|
||||
resource.pop();
|
||||
assert!(resources(&resource).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn inspect_apk_with_cesu8_resources() {
|
||||
use std::io::Write;
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("emoji.apk");
|
||||
fixture(&p, true, 42);
|
||||
let manifest = entry(
|
||||
&mut zip::ZipArchive::new(File::open(&p).unwrap()).unwrap(),
|
||||
"AndroidManifest.xml",
|
||||
8 * 1024 * 1024,
|
||||
)
|
||||
.unwrap();
|
||||
let pool = utf8_pool(&[(2, b"\xed\xa0\xbc\xed\xbc\x90")]);
|
||||
let mut resources = Vec::new();
|
||||
resources.extend(2u16.to_le_bytes());
|
||||
resources.extend(12u16.to_le_bytes());
|
||||
resources.extend(((12 + pool.len()) as u32).to_le_bytes());
|
||||
resources.extend(0u32.to_le_bytes());
|
||||
resources.extend(pool);
|
||||
let mut z = zip::ZipWriter::new(File::create(&p).unwrap());
|
||||
for (name, bytes) in [
|
||||
("AndroidManifest.xml", manifest),
|
||||
("resources.arsc", resources),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
z.write_all(&bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
let m = read(&p).unwrap();
|
||||
assert_eq!(m.package, "com.example.app");
|
||||
assert_eq!(m.name, "Example");
|
||||
assert_eq!(m.activities, ["com.example.app.Main"]);
|
||||
// Exercise the same binary upload -> inspection -> review path as the
|
||||
// PC management panel, without running or installing the APK.
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
let home = d.path().join("home");
|
||||
std::fs::create_dir(&home).unwrap();
|
||||
let upload_dir = crate::apk::upload_dir(&home, bytes.len() as u64).unwrap();
|
||||
let uploads = crate::uploads::Uploads::default();
|
||||
let upload = uploads.start_in(bytes.len() as u64, &upload_dir).unwrap();
|
||||
let id = upload["upload"].as_str().unwrap();
|
||||
uploads.append(id, 0, &bytes).unwrap();
|
||||
let staged = uploads.take(id).unwrap();
|
||||
let review =
|
||||
crate::apk::inspect(&home, &staged.path, &crate::jobs::Cancellation::default())
|
||||
.unwrap();
|
||||
assert_eq!(review["metadata"]["name"], "Example");
|
||||
assert!(review["ticket"].is_string());
|
||||
}
|
||||
#[test]
|
||||
fn package_and_activity_validation() {
|
||||
assert!(valid_package("com.example.app"));
|
||||
@@ -396,6 +780,10 @@ mod tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fixture(path: &Path, launch: bool, version: u32) {
|
||||
fixture_named(path, launch, version, "com.example.app");
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fixture_named(path: &Path, launch: bool, version: u32, package: &str) {
|
||||
use std::io::Write;
|
||||
fn chunk(kind: u16, header: u16, body: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
@@ -408,7 +796,7 @@ pub(crate) fn fixture(path: &Path, launch: bool, version: u32) {
|
||||
let names = [
|
||||
"manifest",
|
||||
"package",
|
||||
"com.example.app",
|
||||
package,
|
||||
"versionCode",
|
||||
"versionName",
|
||||
"1.0",
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct App {
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub icon: Option<String>,
|
||||
pub steam_app_id: Option<u32>,
|
||||
pub launch_unavailable: Option<String>,
|
||||
#[serde(skip)]
|
||||
target: Target,
|
||||
}
|
||||
@@ -212,6 +214,8 @@ fn desktop_apps(home: &Path, dirs: &[PathBuf], steam: &BTreeSet<u32>) -> Vec<App
|
||||
name: name.clone(),
|
||||
kind: "desktop",
|
||||
icon: icon(home, get("Icon"), dirs),
|
||||
steam_app_id: None,
|
||||
launch_unavailable: None,
|
||||
target: Target::Desktop(path),
|
||||
});
|
||||
}
|
||||
@@ -228,6 +232,8 @@ fn lepton_apps(home: &Path) -> Vec<App> {
|
||||
name: v["name"].as_str()?.into(),
|
||||
kind: "lepton",
|
||||
icon: v["icon"].as_str().map(str::to_owned),
|
||||
steam_app_id: v["steamAppId"].as_u64().and_then(|v| u32::try_from(v).ok()),
|
||||
launch_unavailable: v["launchUnavailable"].as_str().map(str::to_owned),
|
||||
target: Target::ManagedLepton(id),
|
||||
})
|
||||
})
|
||||
@@ -249,6 +255,9 @@ pub fn launch(home: &Path, id: &str) -> Result<()> {
|
||||
.into_iter()
|
||||
.find(|a| format!("{}:{}", a.kind, a.id) == id)
|
||||
.context("Application is no longer installed")?;
|
||||
if let Some(reason) = app.launch_unavailable {
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
launch_target(app.target)
|
||||
}
|
||||
fn launch_target(target: Target) -> Result<()> {
|
||||
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
//! Optional, session-user-only input bridges. Each running container retains its pad.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
#[derive(Clone)]
|
||||
pub struct Mount {
|
||||
pub context: String,
|
||||
pub token: String,
|
||||
pub event: PathBuf,
|
||||
pub grab: PathBuf,
|
||||
pub layout: PathBuf,
|
||||
pub ready: PathBuf,
|
||||
}
|
||||
struct Bridge {
|
||||
mount: Mount,
|
||||
child: Child,
|
||||
input: ChildStdin,
|
||||
package: Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Bridges {
|
||||
contexts: BTreeMap<String, Bridge>,
|
||||
active: Option<String>,
|
||||
}
|
||||
static BRIDGE: Mutex<Bridges> = Mutex::new(Bridges {
|
||||
contexts: BTreeMap::new(),
|
||||
active: None,
|
||||
});
|
||||
impl Bridge {
|
||||
fn pause(&mut self) -> Result<()> {
|
||||
writeln!(self.input, "disable")?;
|
||||
self.enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
fn update(&mut self, alive: Option<bool>, focused: bool) -> Result<()> {
|
||||
// Activity/process records can disappear briefly during game updates,
|
||||
// splash-screen handoffs or a query timeout. Keep the mounted device;
|
||||
// only neutralize input until the selected app is foreground again.
|
||||
let enabled = alive != Some(false) && focused && claimed(&self.mount.ready);
|
||||
if self.enabled != enabled {
|
||||
writeln!(self.input, "{}", if enabled { "enable" } else { "disable" })?;
|
||||
self.enabled = enabled;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static SESSION: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
pub fn session_started() {
|
||||
SESSION.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
static WATCHER: OnceLock<()> = OnceLock::new();
|
||||
impl Drop for Bridge {
|
||||
fn drop(&mut self) {
|
||||
let _ = writeln!(self.input, "disable");
|
||||
// A watcher may already have reaped an exited helper. Never signal
|
||||
// that recycled PID while removing its cached mount.
|
||||
if self.child.try_wait().ok().flatten().is_none() {
|
||||
unsafe {
|
||||
libc::kill(self.child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
let began = Instant::now();
|
||||
while began.elapsed() < Duration::from_millis(500) {
|
||||
if self.child.try_wait().ok().flatten().is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = fs::remove_dir_all(&self.mount.ready);
|
||||
}
|
||||
}
|
||||
pub fn stop() {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
bridges.active = None;
|
||||
bridges.contexts.clear();
|
||||
}
|
||||
pub fn set_rumble_enabled(enabled: bool) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
for bridge in bridges.contexts.values_mut() {
|
||||
let _ = writeln!(
|
||||
bridge.input,
|
||||
"{}",
|
||||
if enabled { "rumble-on" } else { "rumble-off" }
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn stop_context(context: &str) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if bridges.active.as_deref() == Some(context) {
|
||||
bridges.active = None;
|
||||
}
|
||||
bridges.contexts.remove(context);
|
||||
}
|
||||
pub fn stop_app(context: &str, package: &str) {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if let Some(bridge) = bridges.contexts.get_mut(context) {
|
||||
if bridge.package.as_deref() == Some(package) {
|
||||
let _ = bridge.pause();
|
||||
bridge.package = None;
|
||||
if bridges.active.as_deref() == Some(context) {
|
||||
bridges.active = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn current(context: &str) -> Option<Mount> {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if bridges
|
||||
.contexts
|
||||
.get_mut(context)
|
||||
.is_some_and(|b| b.child.try_wait().ok().flatten().is_some())
|
||||
{
|
||||
bridges.contexts.remove(context);
|
||||
if bridges.active.as_deref() == Some(context) {
|
||||
bridges.active = None;
|
||||
}
|
||||
}
|
||||
bridges.contexts.get(context).map(|b| b.mount.clone())
|
||||
}
|
||||
pub fn prepare(storage: &Path, context: &str, source: &str, rumble: bool) -> Result<Mount> {
|
||||
ensure!(
|
||||
matches!(source, "steam" | "frame"),
|
||||
"Invalid gamepad input source"
|
||||
);
|
||||
ensure!(SESSION.load(std::sync::atomic::Ordering::Relaxed),"Gamepad launch requires the UI session; open the APK from the management panel or launcher");
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Gamepad input must run as the Steam session user"
|
||||
);
|
||||
let current = current(context);
|
||||
{
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
bridges.active = None;
|
||||
for bridge in bridges.contexts.values_mut() {
|
||||
bridge.pause()?;
|
||||
}
|
||||
if let Some(bridge) = bridges.contexts.get_mut(context) {
|
||||
bridge.package = None;
|
||||
}
|
||||
}
|
||||
if let Some(mount) = current {
|
||||
return Ok(mount);
|
||||
}
|
||||
let exe = std::env::current_exe()?;
|
||||
let distribution = exe
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.context("Missing Framely runtime directory")?;
|
||||
let helper = distribution.join("lib/openvr/framely-gamepad");
|
||||
let grab = distribution.join("lib/openvr/libframely-gamepad-grab.so");
|
||||
let manifest = distribution.join("share/input/gamepad/actions.json");
|
||||
let layout = distribution.join("share/input/gamepad/Vendor_0001_Product_f001.kl");
|
||||
ensure!(
|
||||
helper.is_file() && grab.is_file() && manifest.is_file() && layout.is_file(),
|
||||
"Gamepad input runtime is missing; install a complete Framely build"
|
||||
);
|
||||
let token = hex::encode(rand::random::<[u8; 16]>());
|
||||
let ready = storage.join(format!("gamepad-{token}"));
|
||||
fs::create_dir(&ready)?;
|
||||
fs::set_permissions(&ready, fs::Permissions::from_mode(0o733))?;
|
||||
let app_key = format!("framely.gamepad.{token}");
|
||||
let app_manifest = ready.join("application.vrmanifest");
|
||||
fs::write(
|
||||
&app_manifest,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"applications":[{"app_key":app_key,"launch_type":"binary","is_self_identified":true,
|
||||
"binary_path_linux_arm":helper,"binary_path_linux":helper,"action_manifest_path":manifest,
|
||||
"strings":{"en_us":{"name":"Framely gamepad input"}}}]
|
||||
}))?,
|
||||
)?;
|
||||
fs::set_permissions(&app_manifest, fs::Permissions::from_mode(0o600))?;
|
||||
let log = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(storage.join("logs/gamepad.log"))?;
|
||||
let home = crate::steam::home()?;
|
||||
let steam = if home.join(".local/share/Steam").exists() {
|
||||
home.join(".local/share/Steam")
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
let mut child = Command::new(helper)
|
||||
.env("FRAMELY_GAMEPAD_SOURCE", source)
|
||||
.env("FRAMELY_GAMEPAD_RUMBLE", if rumble { "1" } else { "0" })
|
||||
.env(
|
||||
"FRAMELY_STEAM_SDL_LIBRARY",
|
||||
steam.join("steamrtarm64/libSDL3.so.0"),
|
||||
)
|
||||
.env(
|
||||
"FRAMELY_STEAM_GAMEPAD_INFO",
|
||||
steam.join("config/virtualgamepadinfo.txt"),
|
||||
)
|
||||
.arg(manifest)
|
||||
.arg(&app_manifest)
|
||||
.arg(&app_key)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(log)
|
||||
.spawn()?;
|
||||
let input = child.stdin.take().context("Missing gamepad control pipe")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("Missing gamepad readiness pipe")?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let result = BufReader::new(stdout).read_line(&mut line).map(|_| line);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
let event = rx
|
||||
.recv_timeout(Duration::from_secs(8))
|
||||
.context("Gamepad input did not become ready")
|
||||
.and_then(|v| v.map_err(Into::into))
|
||||
.map(|s| PathBuf::from(s.trim()));
|
||||
let mut bridge = Bridge {
|
||||
mount: Mount {
|
||||
context: context.into(),
|
||||
token,
|
||||
event: PathBuf::new(),
|
||||
grab,
|
||||
layout,
|
||||
ready,
|
||||
},
|
||||
child,
|
||||
input,
|
||||
package: None,
|
||||
enabled: false,
|
||||
};
|
||||
let event = event?;
|
||||
ensure!(
|
||||
valid_node(&event) && fs::metadata(&event)?.file_type().is_char_device(),
|
||||
"Invalid gamepad event device"
|
||||
);
|
||||
bridge.mount.event = event;
|
||||
let mount = bridge.mount.clone();
|
||||
BRIDGE
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contexts
|
||||
.insert(context.into(), bridge);
|
||||
WATCHER.get_or_init(|| {
|
||||
std::thread::spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_millis(700));
|
||||
let target = {
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
bridges
|
||||
.contexts
|
||||
.retain(|_, b| b.child.try_wait().ok().flatten().is_none());
|
||||
if bridges
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|context| !bridges.contexts.contains_key(context))
|
||||
{
|
||||
bridges.active = None;
|
||||
}
|
||||
bridges
|
||||
.active
|
||||
.as_ref()
|
||||
.and_then(|context| bridges.contexts.get(context))
|
||||
.and_then(|b| b.package.as_ref().map(|p| (b.mount.clone(), p.clone())))
|
||||
};
|
||||
let Some((mount, package)) = target else {
|
||||
continue;
|
||||
};
|
||||
let (alive, focused) = crate::apk::gamepad_health(&mount.context, &package);
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
if bridges.active.as_deref() != Some(&mount.context) {
|
||||
continue;
|
||||
}
|
||||
let Some(bridge) = bridges
|
||||
.contexts
|
||||
.get_mut(&mount.context)
|
||||
.filter(|b| b.mount.token == mount.token && b.package.as_deref() == Some(&package))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if bridge.child.try_wait().ok().flatten().is_some()
|
||||
|| bridge.update(alive, focused).is_err()
|
||||
{
|
||||
eprintln!("Gamepad bridge exited for container {}", mount.context);
|
||||
bridges.contexts.remove(&mount.context);
|
||||
bridges.active = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(mount)
|
||||
}
|
||||
pub fn activate(context: &str, package: &str) -> Result<()> {
|
||||
let mount = current(context).context("Gamepad input is no longer running")?;
|
||||
let began = Instant::now();
|
||||
while !claimed(&mount.ready) && began.elapsed() < Duration::from_secs(3) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
ensure!(
|
||||
claimed(&mount.ready),
|
||||
"Android did not claim the virtual gamepad; restart the container and retry"
|
||||
);
|
||||
select_target(context, package)
|
||||
}
|
||||
/// Select routing before native startup acknowledgement. The watcher still
|
||||
/// requires the target activity to be foreground and Android to claim the pad.
|
||||
pub fn select_target(context: &str, package: &str) -> Result<()> {
|
||||
let mount = current(context).context("Gamepad input is no longer running")?;
|
||||
let mut bridges = BRIDGE.lock().unwrap();
|
||||
let bridge = bridges
|
||||
.contexts
|
||||
.get_mut(context)
|
||||
.filter(|b| b.mount.token == mount.token)
|
||||
.context("Gamepad input is no longer running")?;
|
||||
bridge.package = Some(package.into());
|
||||
bridges.active = Some(context.into());
|
||||
Ok(())
|
||||
}
|
||||
fn claimed(directory: &Path) -> bool {
|
||||
let Ok(file) = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(directory.join("ready"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !file.metadata().is_ok_and(|m| m.is_file() && m.len() == 6) {
|
||||
return false;
|
||||
}
|
||||
let mut data = String::new();
|
||||
file.take(16).read_to_string(&mut data).is_ok() && data == "ready\n"
|
||||
}
|
||||
fn valid_node(p: &Path) -> bool {
|
||||
p.parent() == Some(Path::new("/dev/input"))
|
||||
&& p.file_name().and_then(|s| s.to_str()).is_some_and(|s| {
|
||||
s.strip_prefix("event")
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()))
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn temporary_game_handoffs_neutralize_input_without_disconnect() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut child = Command::new("sh")
|
||||
.args([
|
||||
"-c",
|
||||
"while IFS= read -r line; do printf '%s\n' \"$line\"; done",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let input = child.stdin.take().unwrap();
|
||||
let mut output = BufReader::new(child.stdout.take().unwrap());
|
||||
let id = child.id();
|
||||
let mut bridge = Bridge {
|
||||
mount: Mount {
|
||||
context: "test".into(),
|
||||
token: "same-device".into(),
|
||||
event: PathBuf::from("/dev/input/event999"),
|
||||
grab: PathBuf::new(),
|
||||
layout: PathBuf::new(),
|
||||
ready: directory.path().into(),
|
||||
},
|
||||
child,
|
||||
input,
|
||||
package: Some("com.example.game".into()),
|
||||
enabled: false,
|
||||
};
|
||||
let mut read = || {
|
||||
let mut line = String::new();
|
||||
output.read_line(&mut line).unwrap();
|
||||
line
|
||||
};
|
||||
// Native routing may be selected before Android boots. Foreground
|
||||
// alone must not enable a device Android has not yet claimed.
|
||||
bridge.update(Some(true), true).unwrap();
|
||||
assert!(!bridge.enabled);
|
||||
fs::write(directory.path().join("ready"), "ready\n").unwrap();
|
||||
bridge.update(Some(true), false).unwrap();
|
||||
assert!(!bridge.enabled);
|
||||
bridge.update(Some(true), true).unwrap();
|
||||
assert_eq!(read(), "enable\n");
|
||||
bridge.update(Some(false), false).unwrap();
|
||||
assert_eq!(read(), "disable\n");
|
||||
bridge.update(None, false).unwrap();
|
||||
assert!(bridge.child.try_wait().unwrap().is_none());
|
||||
assert!(claimed(&bridge.mount.ready));
|
||||
bridge.update(Some(true), true).unwrap();
|
||||
assert_eq!(read(), "enable\n");
|
||||
bridge.pause().unwrap();
|
||||
assert_eq!(read(), "disable\n");
|
||||
assert_eq!(bridge.child.id(), id);
|
||||
assert_eq!(bridge.mount.token, "same-device");
|
||||
assert!(bridge.child.try_wait().unwrap().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn readiness_requires_a_regular_acknowledgement() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ready");
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::write(&path, "ready\n").unwrap();
|
||||
assert!(claimed(dir.path()));
|
||||
fs::write(&path, "not ready\n").unwrap();
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", &path).unwrap();
|
||||
assert!(!claimed(dir.path()));
|
||||
fs::remove_file(&path).unwrap();
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
|
||||
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
|
||||
assert!(!claimed(dir.path()));
|
||||
}
|
||||
#[test]
|
||||
fn only_event_nodes_are_accepted() {
|
||||
assert!(valid_node(Path::new("/dev/input/event5")));
|
||||
for path in [
|
||||
"/dev/input/event",
|
||||
"/dev/uinput",
|
||||
"/dev/input/../event5",
|
||||
"/dev/input/event5/child",
|
||||
"/tmp/event5",
|
||||
] {
|
||||
assert!(!valid_node(Path::new(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -3,12 +3,14 @@ mod apk_metadata;
|
||||
mod auth;
|
||||
mod desktop;
|
||||
mod diagnostics;
|
||||
mod http;
|
||||
mod file_browser;
|
||||
mod gamepad;
|
||||
mod http;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
mod launcher_search;
|
||||
mod localization;
|
||||
mod manager_files;
|
||||
mod model;
|
||||
mod package;
|
||||
mod planner;
|
||||
@@ -19,6 +21,7 @@ mod service;
|
||||
mod session;
|
||||
mod steam;
|
||||
mod subscriptions;
|
||||
mod terminal;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod update;
|
||||
@@ -38,6 +41,22 @@ struct Cli {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Internal long-lived Steam APK wrapper.
|
||||
ApkSteam {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
token: String,
|
||||
},
|
||||
/// Internal resource-preserving native Steam/Lepton entry.
|
||||
ApkNative {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
token: String,
|
||||
#[arg(last = true, required = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
/// Manage sideloaded APKs as the Steam session user (also works without the panel).
|
||||
Apk {
|
||||
operation: String,
|
||||
@@ -152,6 +171,12 @@ enum Cmd {
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::ApkSteam { app, token } => apk::steam_wrapper(&app, &token),
|
||||
Cmd::ApkNative {
|
||||
app,
|
||||
token,
|
||||
command,
|
||||
} => apk::native_steam(&app, &token, &command),
|
||||
Cmd::Apk {
|
||||
operation,
|
||||
params,
|
||||
@@ -164,6 +189,9 @@ fn run() -> Result<()> {
|
||||
&file.context("Use --file for APK inspection")?
|
||||
)?),
|
||||
"list" => apk::list(&home)?,
|
||||
"input-status" => {
|
||||
apk::input_status(&home, p["app"].as_str().context("Missing app")?)?
|
||||
}
|
||||
"cleanup-list" => apk::cleanup_list(&home)?,
|
||||
"logs" => apk::logs(&home, p["app"].as_str().context("Missing app")?)?,
|
||||
"inspect" => apk::inspect(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -176,9 +176,28 @@ pub struct LauncherAction {
|
||||
pub label: String,
|
||||
pub target: LauncherActionTarget,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct FeedbackSettings {
|
||||
#[serde(default = "yes")]
|
||||
pub haptics: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub sounds: bool,
|
||||
}
|
||||
impl Default for FeedbackSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
haptics: true,
|
||||
sounds: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LauncherSettings {
|
||||
#[serde(default = "yes")]
|
||||
pub prioritize_ui: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub primary_trigger: bool,
|
||||
#[serde(default = "yes")]
|
||||
@@ -188,6 +207,50 @@ pub struct LauncherSettings {
|
||||
#[serde(default = "launcher_categories")]
|
||||
pub all_categories: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct GazeCalibration {
|
||||
#[serde(default)]
|
||||
pub space: String,
|
||||
pub matrix: [f64; 6],
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub error: f64,
|
||||
pub max_error: f64,
|
||||
}
|
||||
impl GazeCalibration {
|
||||
// Preview models have not passed independent validation yet. Accept valid
|
||||
// geometry and finite metrics so validation can measure their actual quality.
|
||||
pub fn validate_preview(&self) -> Result<()> {
|
||||
let m = self.matrix;
|
||||
let det = m[0] * m[4] - m[1] * m[3];
|
||||
ensure!(
|
||||
self.space == "headAngles"
|
||||
&& m.iter().all(|v| v.is_finite() && v.abs() <= 4.0)
|
||||
&& (0.25..=4.0).contains(&det)
|
||||
&& m[2].abs() <= 0.5
|
||||
&& m[5].abs() <= 0.5
|
||||
&& (320..=8192).contains(&self.width)
|
||||
&& (240..=8192).contains(&self.height)
|
||||
&& self.error.is_finite()
|
||||
&& self.error >= 0.0
|
||||
&& self.max_error.is_finite()
|
||||
&& self.max_error >= 0.0
|
||||
&& self.max_error + 1e-12 >= self.error,
|
||||
"Invalid gaze calibration model"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.validate_preview()?;
|
||||
ensure!(
|
||||
self.error <= 0.035 && self.max_error <= 0.065,
|
||||
"Gaze calibration validation error exceeds the allowed limit"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn launcher_categories() -> Vec<String> {
|
||||
["plugin", "steam", "lepton", "desktop"]
|
||||
.into_iter()
|
||||
@@ -200,6 +263,7 @@ fn menu_timeout() -> u32 {
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prioritize_ui: true,
|
||||
primary_trigger: true,
|
||||
menu_auto_close: true,
|
||||
menu_timeout_seconds: 10,
|
||||
@@ -785,8 +849,12 @@ pub struct Database {
|
||||
#[serde(default)]
|
||||
pub notification_settings: NotificationSettings,
|
||||
#[serde(default)]
|
||||
pub feedback: FeedbackSettings,
|
||||
#[serde(default)]
|
||||
pub launcher: LauncherSettings,
|
||||
#[serde(default)]
|
||||
pub gaze_calibration: Option<GazeCalibration>,
|
||||
#[serde(default)]
|
||||
pub steam_favorites: std::collections::BTreeSet<u32>,
|
||||
#[serde(default)]
|
||||
pub launcher_order: Vec<String>,
|
||||
|
||||
@@ -1058,6 +1058,21 @@ impl Service {
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.gaze.calibration.get" => Ok(json!(self.db.gaze_calibration)),
|
||||
"launcher.gaze.calibration.save" => {
|
||||
let calibration: Option<GazeCalibration> = serde_json::from_value(p)?;
|
||||
if let Some(value) = &calibration {
|
||||
value.validate()?;
|
||||
}
|
||||
self.db.gaze_calibration = calibration;
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"feedback.settings.save" => {
|
||||
self.db.feedback = serde_json::from_value(p)?;
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.settings.save" => {
|
||||
let settings: LauncherSettings = serde_json::from_value(p)?;
|
||||
settings.validate()?;
|
||||
@@ -2942,3 +2957,39 @@ mod notification_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feedback_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn feedback_defaults_persist_and_reject_invalid_settings() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
|
||||
assert!(core.db.feedback.haptics && core.db.feedback.sounds);
|
||||
core.handle(
|
||||
"feedback.settings.save",
|
||||
json!({"haptics":false,"sounds":true}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
|
||||
assert!(reloaded
|
||||
.handle("feedback.settings.save", json!({"haptics":"off"}))
|
||||
.is_err());
|
||||
assert!(reloaded
|
||||
.handle("feedback.settings.save", json!({"other":true}))
|
||||
.is_err());
|
||||
assert!(!reloaded.db.feedback.haptics && reloaded.db.feedback.sounds);
|
||||
reloaded
|
||||
.handle(
|
||||
"feedback.settings.save",
|
||||
json!({"haptics":false,"sounds":false}),
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = Service::load(root.path(), 1000).unwrap();
|
||||
assert!(!reloaded.db.feedback.haptics && !reloaded.db.feedback.sounds);
|
||||
let old: Database =
|
||||
serde_json::from_value(json!({"plugins":{},"sources":[],"safeMode":false})).unwrap();
|
||||
assert!(old.feedback.haptics && old.feedback.sounds);
|
||||
}
|
||||
}
|
||||
+621
-31
@@ -28,10 +28,12 @@ struct Agent {
|
||||
network_status: Mutex<Value>,
|
||||
frame_access: Mutex<std::collections::BTreeMap<String, FrameAccess>>,
|
||||
native_key: String,
|
||||
commands: Mutex<VecDeque<Value>>,
|
||||
commands: Arc<Mutex<VecDeque<Value>>>,
|
||||
events: Mutex<EventLog>,
|
||||
jobs: crate::jobs::Jobs,
|
||||
launcher_search: Mutex<crate::launcher_search::SearchCache>,
|
||||
terminals: crate::terminal::Terminals,
|
||||
files: Arc<crate::manager_files::Files>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -368,9 +370,14 @@ impl Agent {
|
||||
let badge = self
|
||||
.core("host.notification.badge", json!({}))
|
||||
.unwrap_or(json!(0));
|
||||
let database = self
|
||||
.core("status", json!({}))
|
||||
.ok()
|
||||
.map(|v| v["database"].clone())
|
||||
.unwrap_or(json!({}));
|
||||
return send_json(
|
||||
r,
|
||||
json!({"commands":commands,"notificationBadge":badge,"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}),
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
|
||||
);
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
@@ -520,9 +527,20 @@ impl Agent {
|
||||
"Session authentication failed"
|
||||
);
|
||||
if path.starts_with("/host/files/") {
|
||||
ensure!(!remote && r.method() == &Method::Post, "File browser is only available inside Frame");
|
||||
ensure!(r.headers().iter().any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin file browser request denied");
|
||||
ensure!(self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明");
|
||||
ensure!(
|
||||
!remote && r.method() == &Method::Post,
|
||||
"File browser is only available inside Frame"
|
||||
);
|
||||
ensure!(
|
||||
r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin),
|
||||
"Cross-origin file browser request denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(65537).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 65536, "File browser request too large");
|
||||
@@ -531,16 +549,39 @@ impl Agent {
|
||||
let home = crate::steam::home()?;
|
||||
match path.as_str() {
|
||||
"/host/files/list" => crate::file_browser::list(&home, ¶ms),
|
||||
"/host/files/complete" => {
|
||||
let id = params["id"].as_str().filter(|s| !s.is_empty() && s.len() <= 128).context("Invalid file dialog")?;
|
||||
"/host/files/apk-inspect" => {
|
||||
let paths = crate::file_browser::selection(&home, ¶ms)?;
|
||||
push(&self.commands, json!({"kind":"file.dialog.complete","id":id,"paths":paths}));
|
||||
ensure!(
|
||||
paths.len() == 1 && params["directory"] != true,
|
||||
"Select one APK file"
|
||||
);
|
||||
let path = paths.into_iter().next().unwrap();
|
||||
self.jobs.task("apk:inspect", move |cancel| {
|
||||
crate::apk::inspect_local(&home, &path, &cancel)
|
||||
})
|
||||
}
|
||||
"/host/files/complete" => {
|
||||
let id = params["id"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty() && s.len() <= 128)
|
||||
.context("Invalid file dialog")?;
|
||||
let paths = crate::file_browser::selection(&home, ¶ms)?;
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"file.dialog.complete","id":id,"paths":paths}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown file browser operation"),
|
||||
}
|
||||
})();
|
||||
return send_json(r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}) });
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(path) = path.strip_prefix("/api/upload/") {
|
||||
ensure!(r.method() == &Method::Post, "Invalid upload method");
|
||||
@@ -566,6 +607,171 @@ impl Agent {
|
||||
let result = self.jobs.uploads.append(id, offset, &bytes)?;
|
||||
return send_json(r, json!({"result":result}));
|
||||
}
|
||||
if let Some(id) = path.strip_prefix("/manager-api/file-content/") {
|
||||
ensure!(
|
||||
matches!(r.method(), Method::Get | Method::Head),
|
||||
"Invalid download method"
|
||||
);
|
||||
ensure!(
|
||||
!r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Sec-Fetch-Site") && h.value.as_str() == "cross-site"),
|
||||
"Cross-origin download denied"
|
||||
);
|
||||
ensure!(
|
||||
!r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() != expected_origin),
|
||||
"Cross-origin download denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
let (file, inline) = self.files.content(id)?;
|
||||
return serve_user_file(r, &file, inline);
|
||||
}
|
||||
if path == "/manager-api/files" || path.starts_with("/manager-api/file-upload/") {
|
||||
ensure!(r.method() == &Method::Post, "Invalid file manager method");
|
||||
ensure!(
|
||||
r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin),
|
||||
"Cross-origin manager request denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
if let Some(upload) = path.strip_prefix("/manager-api/file-upload/") {
|
||||
let (id, offset) = upload.split_once('/').context("Invalid upload path")?;
|
||||
let offset: u64 = offset.parse()?;
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader()
|
||||
.take((crate::uploads::CHUNK + 1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
let result = self.files.append(id, offset, &bytes);
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader()
|
||||
.take(32 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 32 * 1024 * 1024,
|
||||
"File manager request too large"
|
||||
);
|
||||
let result = (|| -> Result<Value> {
|
||||
let p: Value = serde_json::from_slice(&bytes)?;
|
||||
let operation = p["operation"].as_str().unwrap_or("list");
|
||||
if matches!(
|
||||
operation,
|
||||
"copy"
|
||||
| "move"
|
||||
| "delete"
|
||||
| "search"
|
||||
| "compress"
|
||||
| "extract"
|
||||
| "download.batch"
|
||||
) {
|
||||
let files = self.files.clone();
|
||||
let home = crate::steam::home()?;
|
||||
return self.jobs.task_progress(
|
||||
&format!("files:{operation}:{}", crate::manager_files::token()),
|
||||
move |cancel, progress| files.task(&home, &p, cancel, progress),
|
||||
);
|
||||
}
|
||||
self.files.api(&crate::steam::home()?, &p)
|
||||
})();
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if path == "/manager-api/terminal" || path == "/manager-api/terminal/ws" {
|
||||
ensure!(
|
||||
r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin),
|
||||
"Cross-origin manager request denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
if path.ends_with("/ws") {
|
||||
ensure!(r.method() == &Method::Get, "Invalid terminal method");
|
||||
let query = r.url().split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let id = url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(k, _)| k == "id")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
.context("Missing terminal")?;
|
||||
let key = r
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Sec-WebSocket-Key"))
|
||||
.context("Missing WebSocket key")?
|
||||
.value
|
||||
.as_str();
|
||||
ensure!(r.headers().iter().any(|h|h.field.equiv("Sec-WebSocket-Version")&&h.value.as_str()=="13"),"Unsupported WebSocket version");
|
||||
ensure!(
|
||||
r.headers().iter().any(|h| h.field.equiv("Upgrade")
|
||||
&& h.value.as_str().eq_ignore_ascii_case("websocket")),
|
||||
"Missing WebSocket upgrade"
|
||||
);
|
||||
ensure!(
|
||||
r.headers().iter().any(|h| h.field.equiv("Connection")
|
||||
&& h.value
|
||||
.as_str()
|
||||
.split(',')
|
||||
.any(|v| v.trim().eq_ignore_ascii_case("upgrade"))),
|
||||
"Missing WebSocket connection upgrade"
|
||||
);
|
||||
let accept = tungstenite::handshake::derive_accept_key(key.as_bytes());
|
||||
let key = self.network_key.lock().unwrap().clone();
|
||||
let response =
|
||||
Response::empty(101).with_header(header("Sec-WebSocket-Accept", &accept));
|
||||
let stream = r.upgrade("websocket", response);
|
||||
let config = tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(262144))
|
||||
.max_frame_size(Some(262144));
|
||||
let socket = tungstenite::WebSocket::from_raw_socket(
|
||||
stream,
|
||||
tungstenite::protocol::Role::Server,
|
||||
Some(config),
|
||||
);
|
||||
let _ = self.terminals.websocket(&id, socket, || {
|
||||
!remote || *self.network_key.lock().unwrap() == key
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(r.method() == &Method::Post, "Invalid manager method");
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(8193).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 8192, "Manager request too large");
|
||||
let result = (|| -> Result<Value> {
|
||||
self.terminals.api(
|
||||
&crate::steam::home()?,
|
||||
&serde_json::from_slice::<Value>(&bytes)?,
|
||||
)
|
||||
})();
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if path == "/api" && r.method() == &Method::Post {
|
||||
let origin = r
|
||||
.headers()
|
||||
@@ -614,7 +820,7 @@ impl Agent {
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
r.respond(response)?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -728,18 +934,86 @@ impl Agent {
|
||||
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
|
||||
"desktop.launch" => {
|
||||
let id = p["app"].as_str().context("Missing desktop app")?;
|
||||
crate::desktop::launch(&crate::steam::home()?, id)?;
|
||||
push(&self.commands, json!({"kind":"launcher.close"}));
|
||||
let launcher_session = p["launcherSession"].as_u64();
|
||||
if let Some(app) = id.strip_prefix("lepton:") {
|
||||
let commands = self.commands.clone();
|
||||
crate::apk::launch_app_with_progress(
|
||||
&crate::steam::home()?,
|
||||
app,
|
||||
Arc::new(move |event| {
|
||||
if event["phase"] == "started" {
|
||||
push(
|
||||
&commands,
|
||||
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
|
||||
);
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
} else {
|
||||
crate::desktop::launch(&crate::steam::home()?, id)?;
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
|
||||
);
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
|
||||
"steam.list" => {
|
||||
if p["remoteOnly"] == true {
|
||||
Ok(crate::steam::remote_snapshot())
|
||||
} else {
|
||||
Ok(json!(crate::steam::discover_launcher(
|
||||
&crate::steam::home()?
|
||||
)))
|
||||
}
|
||||
}
|
||||
"steam.launch" => {
|
||||
let launcher_session = p["launcherSession"].as_u64();
|
||||
let id = p["app"]
|
||||
.as_u64()
|
||||
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
|
||||
.context("Invalid Steam app")? as u32;
|
||||
crate::steam::launch(id)?;
|
||||
push(&self.commands, json!({"kind":"launcher.close"}));
|
||||
if let Some(client) = p["remoteClient"].as_str() {
|
||||
crate::steam::launch_remote(id, client)?;
|
||||
} else {
|
||||
crate::steam::launch(id)?;
|
||||
}
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"host.launcher.calibration.target" => {
|
||||
let id = p["id"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty() && id.len() <= 128)
|
||||
.context("Invalid calibration target")?;
|
||||
for coordinate in ["x", "y"] {
|
||||
let value = p[coordinate]
|
||||
.as_f64()
|
||||
.context("Invalid calibration target")?;
|
||||
ensure!(
|
||||
value.is_finite() && (0.0..=1.0).contains(&value),
|
||||
"Invalid calibration target"
|
||||
);
|
||||
}
|
||||
let preview: Option<crate::model::GazeCalibration> =
|
||||
serde_json::from_value(p["preview"].clone())?;
|
||||
if let Some(calibration) = &preview {
|
||||
calibration.validate_preview()?;
|
||||
}
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"gaze.target","id":id,"x":p["x"],"y":p["y"],"preview":preview}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"host.launcher.calibrate" => {
|
||||
push(
|
||||
&self.commands,
|
||||
json!({"kind":"launcher.calibrate","active":p["active"].as_bool().unwrap_or(true)}),
|
||||
);
|
||||
Ok(json!(true))
|
||||
}
|
||||
"host.launcher.close" => {
|
||||
@@ -885,6 +1159,7 @@ impl Agent {
|
||||
| "notification-settings"
|
||||
| "launcher-settings"
|
||||
| "apk"
|
||||
| "apk-settings"
|
||||
)
|
||||
)
|
||||
{
|
||||
@@ -975,13 +1250,17 @@ impl Agent {
|
||||
) || view.starts_with("framely.window.")),
|
||||
"Invalid haptic view"
|
||||
);
|
||||
let feedback = p["feedback"].as_str().unwrap_or("hover");
|
||||
ensure!(
|
||||
matches!(feedback, "hover" | "activate"),
|
||||
"Invalid feedback kind"
|
||||
);
|
||||
let mut commands = self.commands.lock().unwrap();
|
||||
if !commands
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "haptic" && c["view"] == view)
|
||||
{
|
||||
if !commands.iter().any(|c| {
|
||||
c["kind"] == "haptic" && c["view"] == view && c["feedback"] == feedback
|
||||
}) {
|
||||
ensure!(commands.len() < 256, "Native command queue full");
|
||||
commands.push_back(json!({"kind":"haptic","view":view}));
|
||||
commands.push_back(json!({"kind":"haptic","view":view,"feedback":feedback}));
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
@@ -1016,7 +1295,10 @@ impl Agent {
|
||||
| "plugin.launch.context"
|
||||
| "plugin.launch"
|
||||
| "plugin.launcher.action"
|
||||
| "feedback.settings.save"
|
||||
| "launcher.settings.save"
|
||||
| "launcher.gaze.calibration.get"
|
||||
| "launcher.gaze.calibration.save"
|
||||
| "launcher.order.save"
|
||||
| "launcher.favorite"
|
||||
| "steam.favorite"
|
||||
@@ -1288,6 +1570,104 @@ fn send_file(r: Request, p: &Path, cors: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_user_file(r: Request, path: &Path, inline: bool) -> Result<()> {
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
ensure!(file.metadata()?.is_file(), "Select a regular file");
|
||||
let length = file.metadata()?.len();
|
||||
let range = r
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str());
|
||||
let mut start = 0;
|
||||
let mut end = length.saturating_sub(1);
|
||||
let mut partial = false;
|
||||
if let Some(range) = range {
|
||||
let parsed = (|| -> Result<(u64, u64)> {
|
||||
let value = range.strip_prefix("bytes=").context("Invalid range")?;
|
||||
ensure!(!value.contains(','), "Multiple ranges are not supported");
|
||||
let (first, last) = value.split_once('-').context("Invalid range")?;
|
||||
let (first, last) = if first.is_empty() {
|
||||
let size: u64 = last.parse()?;
|
||||
ensure!(size > 0, "Invalid range");
|
||||
(length.saturating_sub(size), length.saturating_sub(1))
|
||||
} else {
|
||||
let first: u64 = first.parse()?;
|
||||
let last = if last.is_empty() {
|
||||
length.saturating_sub(1)
|
||||
} else {
|
||||
last.parse::<u64>()?.min(length.saturating_sub(1))
|
||||
};
|
||||
(first, last)
|
||||
};
|
||||
ensure!(
|
||||
length > 0 && first < length && first <= last,
|
||||
"Range is outside the file"
|
||||
);
|
||||
Ok((first, last))
|
||||
})();
|
||||
match parsed {
|
||||
Ok((a, b)) => {
|
||||
start = a;
|
||||
end = b;
|
||||
partial = true;
|
||||
}
|
||||
Err(_) => {
|
||||
r.respond(
|
||||
Response::empty(416)
|
||||
.with_header(header("Content-Range", &format!("bytes */{length}"))),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = if length == 0 { 0 } else { end - start + 1 };
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mime = if inline {
|
||||
crate::manager_files::media_type(path).unwrap_or("application/octet-stream")
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let encoded = url::form_urlencoded::byte_serialize(filename.as_bytes())
|
||||
.collect::<String>()
|
||||
.replace('+', "%20");
|
||||
let mut headers = vec![
|
||||
header("Content-Type", mime),
|
||||
header("Accept-Ranges", "bytes"),
|
||||
header("X-Content-Type-Options", "nosniff"),
|
||||
header("Cache-Control", "no-store"),
|
||||
header("Content-Security-Policy", "default-src 'none'; sandbox"),
|
||||
header(
|
||||
"Content-Disposition",
|
||||
&format!(
|
||||
"{}; filename*=UTF-8''{encoded}",
|
||||
if inline { "inline" } else { "attachment" }
|
||||
),
|
||||
),
|
||||
];
|
||||
if partial {
|
||||
headers.push(header(
|
||||
"Content-Range",
|
||||
&format!("bytes {start}-{end}/{length}"),
|
||||
));
|
||||
}
|
||||
let response = Response::new(
|
||||
StatusCode(if partial { 206 } else { 200 }),
|
||||
headers,
|
||||
file.take(size),
|
||||
Some(size as usize),
|
||||
None,
|
||||
);
|
||||
r.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
socket: PathBuf,
|
||||
state: PathBuf,
|
||||
@@ -1299,6 +1679,9 @@ pub fn serve(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"UI session must not run as root"
|
||||
);
|
||||
crate::gamepad::session_started();
|
||||
crate::apk::steam_session_started()?;
|
||||
crate::apk::lifecycle_started();
|
||||
let server = Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
let port = server
|
||||
.server_addr()
|
||||
@@ -1325,12 +1708,21 @@ pub fn serve(
|
||||
network_status: Mutex::new(json!({"enabled":false})),
|
||||
frame_access: Mutex::default(),
|
||||
native_key: native_key.clone(),
|
||||
commands: Mutex::default(),
|
||||
commands: Arc::default(),
|
||||
events: Mutex::default(),
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
terminals: crate::terminal::Terminals::default(),
|
||||
files: Arc::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
});
|
||||
let file_agent = agent.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
let _ = file_agent.files.maintain(&home);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
});
|
||||
let network_agent = agent.clone();
|
||||
std::thread::spawn(move || network_listener(network_agent, || true));
|
||||
let subscriptions_agent = agent.clone();
|
||||
@@ -1412,6 +1804,15 @@ pub fn serve(
|
||||
"--no-default-browser-check",
|
||||
])
|
||||
.env("FRAMELY_NATIVE_TOKEN", &native_key)
|
||||
.env("FRAMELY_STEAM_ROOT", {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
|
||||
let steam = home.join(".local/share/Steam");
|
||||
if steam.is_dir() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
}
|
||||
})
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.status();
|
||||
@@ -1616,7 +2017,7 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn binary_upload_endpoint_requires_auth_origin_and_order() {
|
||||
fn binary_upload_endpoint_requires_auth_origin_and_accepts_out_of_order_chunks() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
@@ -1656,6 +2057,17 @@ mod tests {
|
||||
.set("Origin", "http://evil.test")
|
||||
.send_bytes(b"a")
|
||||
.is_err());
|
||||
let tail: Value = client
|
||||
.post(&format!("{}/api/upload/{id}/2", agent.origin))
|
||||
.set("Cookie", &cookie)
|
||||
.set("Origin", &agent.origin)
|
||||
.send_bytes(b"c")
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap();
|
||||
assert_eq!(tail["result"]["received"], 3);
|
||||
assert_eq!(tail["result"]["uploadedBytes"], 1);
|
||||
assert!(agent.jobs.uploads.take(&id).is_err());
|
||||
let response: Value = client
|
||||
.post(&url)
|
||||
.set("Cookie", &cookie)
|
||||
@@ -1710,10 +2122,12 @@ mod tests {
|
||||
network_status: Mutex::new(json!({"enabled":false})),
|
||||
frame_access: Mutex::default(),
|
||||
native_key: random_key(),
|
||||
commands: Mutex::default(),
|
||||
commands: Arc::default(),
|
||||
events: Mutex::default(),
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
terminals: crate::terminal::Terminals::default(),
|
||||
files: Arc::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
})
|
||||
}
|
||||
@@ -1731,6 +2145,55 @@ mod tests {
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn calibration_preview_allows_unvalidated_error_but_rejects_invalid_geometry() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let mut core =
|
||||
crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
|
||||
let socket = UnixListener::bind(&agent.socket).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let core_stop = stop.clone();
|
||||
let core_worker = std::thread::spawn(move || {
|
||||
while !core_stop.load(Ordering::Relaxed) {
|
||||
if let Ok((mut stream, _)) = socket.accept() {
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
let response = ipc::response(core.handle(
|
||||
request["method"].as_str().unwrap(),
|
||||
request["params"].clone(),
|
||||
));
|
||||
ipc::write(&mut stream, &response).unwrap();
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
});
|
||||
let http = http_server(server, agent.clone(), stop.clone());
|
||||
let request = |preview: Value| -> Value {
|
||||
ureq::post(&format!("{}/api", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", &agent.origin)
|
||||
.send_json(json!({"method":"host.launcher.calibration.target", "params":{"id":"verify:1","x":0.3,"y":0.5,"preview":preview}}))
|
||||
.unwrap().into_json().unwrap()
|
||||
};
|
||||
let preview = json!({"space":"headAngles","matrix":[1.0,0.0,0.1,0.0,1.0,-0.1],"width":1440,"height":800,"error":0.08,"maxError":0.15});
|
||||
let allowed = request(preview.clone());
|
||||
let mut invalid = preview.clone();
|
||||
invalid["matrix"][0] = json!(-1);
|
||||
let rejected = request(invalid);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
http.join().unwrap();
|
||||
core_worker.join().unwrap();
|
||||
assert_eq!(allowed["result"], json!(true), "{allowed}");
|
||||
assert!(rejected.get("error").is_some(), "{rejected}");
|
||||
let commands = agent.commands.lock().unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0]["preview"], preview);
|
||||
let model: crate::model::GazeCalibration = serde_json::from_value(preview).unwrap();
|
||||
assert!(model.validate().is_err()); // Still cannot persist a poor result.
|
||||
}
|
||||
#[test]
|
||||
fn launcher_favorites_pass_through_real_http_session_and_persist() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
@@ -2010,6 +2473,91 @@ mod tests {
|
||||
core_worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_http_accepts_five_mib_after_json_escaping() {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let file = root.path().join("generated text.txt");
|
||||
fs::write(&file, b"old").unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let revision = agent
|
||||
.files
|
||||
.api(root.path(), &json!({"operation":"read","path":file}))
|
||||
.unwrap()["revision"]
|
||||
.clone();
|
||||
let listener = UnixListener::bind(&agent.socket).unwrap();
|
||||
let core = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
assert_eq!(request["method"], "agreement.status");
|
||||
ipc::write(&mut stream, &json!({"result":{"accepted":true}})).unwrap();
|
||||
});
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let worker = http_server(server, agent.clone(), stop.clone());
|
||||
let text = "\"".repeat(5 * 1024 * 1024);
|
||||
let value: Value = ureq::post(&format!("{}/manager-api/files", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", &agent.origin)
|
||||
.send_json(json!({"operation":"write","path":file,"text":text,"revision":revision}))
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap();
|
||||
assert!(value["error"].is_null(), "{value}");
|
||||
assert_eq!(
|
||||
value["result"]["text"].as_str().unwrap().len(),
|
||||
5 * 1024 * 1024
|
||||
);
|
||||
assert_eq!(fs::metadata(file).unwrap().len(), 5 * 1024 * 1024);
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
worker.join().unwrap();
|
||||
core.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_streams_support_range_head_and_safe_download_headers() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("generated sample.mp4");
|
||||
fs::write(&path, b"0123456789").unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", server.server_addr());
|
||||
let thread = std::thread::spawn(move || {
|
||||
for _ in 0..7 {
|
||||
serve_user_file(server.recv().unwrap(), &path, true).unwrap();
|
||||
}
|
||||
});
|
||||
for (range, expected) in [
|
||||
("bytes=2-5", "2345"),
|
||||
("bytes=-3", "789"),
|
||||
("bytes=8-", "89"),
|
||||
] {
|
||||
let reply = ureq::get(&origin).set("Range", range).call().unwrap();
|
||||
assert_eq!(reply.status(), 206);
|
||||
assert_eq!(reply.header("Accept-Ranges"), Some("bytes"));
|
||||
assert_eq!(reply.header("X-Content-Type-Options"), Some("nosniff"));
|
||||
assert_eq!(reply.into_string().unwrap(), expected);
|
||||
}
|
||||
for range in ["bytes=10-", "bytes=1-2,4-5"] {
|
||||
assert!(matches!(
|
||||
ureq::get(&origin).set("Range", range).call(),
|
||||
Err(ureq::Error::Status(416, _))
|
||||
));
|
||||
}
|
||||
let reply = ureq::get(&origin).call().unwrap();
|
||||
assert_eq!(reply.header("Content-Type"), Some("video/mp4"));
|
||||
assert!(reply
|
||||
.header("Content-Disposition")
|
||||
.unwrap()
|
||||
.contains("generated%20sample.mp4"));
|
||||
assert_eq!(reply.into_string().unwrap(), "0123456789");
|
||||
let reply = ureq::head(&origin).call().unwrap();
|
||||
assert_eq!(reply.header("Content-Length"), Some("10"));
|
||||
assert!(reply.into_string().unwrap().is_empty());
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
|
||||
let password = "密码".repeat(70);
|
||||
@@ -2081,8 +2629,17 @@ mod tests {
|
||||
assert!(ureq::get(&format!("{origin}/boot/{}", agent.web_key))
|
||||
.call()
|
||||
.is_err());
|
||||
for path in ["/host/files/list", "/host/files/complete"] {
|
||||
assert!(matches!(ureq::post(&format!("{origin}{path}")).set("Origin", &origin).send_json(json!({})), Err(ureq::Error::Status(403, _))));
|
||||
for path in [
|
||||
"/host/files/list",
|
||||
"/host/files/complete",
|
||||
"/host/files/apk-inspect",
|
||||
] {
|
||||
assert!(matches!(
|
||||
ureq::post(&format!("{origin}{path}"))
|
||||
.set("Origin", &origin)
|
||||
.send_json(json!({})),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
}
|
||||
assert!(ureq::get(&format!("{origin}/host/poll"))
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
@@ -2206,6 +2763,30 @@ mod tests {
|
||||
.set("Origin", "null")
|
||||
.send_json(json!({"method":"ui.events"}))
|
||||
.is_err());
|
||||
for route in [
|
||||
"/manager-api/files",
|
||||
"/manager-api/terminal",
|
||||
"/manager-api/file-upload/id/0",
|
||||
] {
|
||||
for origin in ["null", "http://other.example", ""] {
|
||||
assert!(ureq::post(&format!("{}{route}", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", origin)
|
||||
.send_json(json!({"operation":"list"}))
|
||||
.is_err());
|
||||
}
|
||||
assert!(ureq::post(&format!("{}{route}", agent.origin))
|
||||
.set("Origin", &agent.origin)
|
||||
.send_json(json!({"operation":"list"}))
|
||||
.is_err());
|
||||
}
|
||||
assert!(
|
||||
ureq::get(&format!("{}/manager-api/terminal/ws?id=x", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", "null")
|
||||
.call()
|
||||
.is_err()
|
||||
);
|
||||
let v: Value = ureq::post(&url)
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.set("Origin", &agent.origin)
|
||||
@@ -2239,16 +2820,20 @@ mod tests {
|
||||
"host.ui.visibility",
|
||||
"host.notification.badge",
|
||||
"status",
|
||||
"launcher.gaze.calibration.get",
|
||||
"agreement.status",
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
assert_eq!(request["method"], expected);
|
||||
ipc::write(
|
||||
&mut stream,
|
||||
&json!({"result":core.handle(request["method"].as_str().unwrap(),request["params"].clone()).unwrap()}),
|
||||
)
|
||||
.unwrap();
|
||||
let response = match core.handle(
|
||||
request["method"].as_str().unwrap(),
|
||||
request["params"].clone(),
|
||||
) {
|
||||
Ok(value) => json!({"result":value}),
|
||||
Err(error) => json!({"error":error.to_string()}),
|
||||
};
|
||||
ipc::write(&mut stream, &response).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
core.handle("ui.visibility.get", json!({})).unwrap()["captureObscured"],
|
||||
@@ -2263,10 +2848,15 @@ mod tests {
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
.send_json(json!({"menu":true}))
|
||||
.is_err());
|
||||
ureq::post(&url)
|
||||
let poll: Value = ureq::post(&url)
|
||||
.set("X-Framely-Native", &agent.native_key)
|
||||
.send_json(json!({"menu":true}))
|
||||
.unwrap()
|
||||
.into_json()
|
||||
.unwrap();
|
||||
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":true}));
|
||||
assert!(poll.get("gazeCalibration").is_some());
|
||||
assert!(poll["launcher"].is_object());
|
||||
assert!(agent
|
||||
.api(json!({"method":"host.ui.visibility","params":{"menu":false}}))
|
||||
.is_err());
|
||||
|
||||
+156
-68
@@ -1,4 +1,5 @@
|
||||
//! Local Steam library discovery. No account API or network access is needed.
|
||||
//! Local Steam libraries and connected Remote Play hosts.
|
||||
mod remote;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
@@ -9,6 +10,9 @@ use std::{
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct App {
|
||||
pub remote: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remote_client: Option<String>,
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
@@ -202,77 +206,13 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let dir = steam.join("appcache/librarycache").join(id.to_string());
|
||||
let mut candidates: Vec<_> = fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
// Newer Steam clients keep named artwork one level below a content hash.
|
||||
let nested: Vec<_> = candidates
|
||||
.iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.take(16)
|
||||
.cloned()
|
||||
.collect();
|
||||
for path in nested {
|
||||
candidates.extend(
|
||||
fs::read_dir(path)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(32)
|
||||
.map(|e| e.path()),
|
||||
);
|
||||
}
|
||||
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
|
||||
candidates.push(
|
||||
steam
|
||||
.join("appcache/librarycache")
|
||||
.join(format!("{id}_{suffix}")),
|
||||
);
|
||||
}
|
||||
let artwork_fit = |p: &Path| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if name.contains("600x900") || name == "library_capsule.jpg" {
|
||||
(0, "cover")
|
||||
} else if name.contains("header") {
|
||||
(1, "cover")
|
||||
} else if name == "logo.png" {
|
||||
(2, "contain")
|
||||
} else {
|
||||
(3, "contain")
|
||||
}
|
||||
};
|
||||
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
|
||||
let icon = candidates.into_iter().take(32).find_map(|p| {
|
||||
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let b = fs::read(&p).ok()?;
|
||||
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
use base64::Engine;
|
||||
Some((
|
||||
format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(b)
|
||||
),
|
||||
artwork_fit(&p).1,
|
||||
))
|
||||
});
|
||||
let icon = cached_artwork(&steam, id);
|
||||
apps.insert(
|
||||
id,
|
||||
App {
|
||||
id,
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
name: name.into(),
|
||||
kind: if text(v, "installdir").is_some_and(|dir| {
|
||||
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
|
||||
@@ -295,6 +235,117 @@ pub fn discover(home: &Path) -> Vec<App> {
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
fn cached_artwork(steam: &Path, id: u32) -> Option<(String, &'static str)> {
|
||||
let dir = steam.join("appcache/librarycache").join(id.to_string());
|
||||
let mut candidates: Vec<_> = fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
// Newer Steam clients keep named artwork one level below a content hash.
|
||||
let nested: Vec<_> = candidates
|
||||
.iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.take(16)
|
||||
.cloned()
|
||||
.collect();
|
||||
for path in nested {
|
||||
candidates.extend(
|
||||
fs::read_dir(path)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(32)
|
||||
.map(|e| e.path()),
|
||||
);
|
||||
}
|
||||
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
|
||||
candidates.push(
|
||||
steam
|
||||
.join("appcache/librarycache")
|
||||
.join(format!("{id}_{suffix}")),
|
||||
);
|
||||
}
|
||||
let artwork_fit = |p: &Path| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if name.contains("600x900") || name == "library_capsule.jpg" {
|
||||
(0, "cover")
|
||||
} else if name.contains("header") {
|
||||
(1, "cover")
|
||||
} else if name == "logo.png" {
|
||||
(2, "contain")
|
||||
} else {
|
||||
(3, "contain")
|
||||
}
|
||||
};
|
||||
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
|
||||
candidates.into_iter().take(32).find_map(|p| {
|
||||
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let b = fs::read(&p).ok()?;
|
||||
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
use base64::Engine;
|
||||
Some((
|
||||
format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(b)
|
||||
),
|
||||
artwork_fit(&p).1,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remote_snapshot() -> serde_json::Value {
|
||||
serde_json::json!(remote::cached())
|
||||
}
|
||||
pub fn discover_launcher(home: &Path) -> Vec<App> {
|
||||
merge_remote(home, discover(home), remote::cached())
|
||||
}
|
||||
fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) -> Vec<App> {
|
||||
let mut apps: BTreeMap<_, _> = local.into_iter().map(|a| (a.id, a)).collect();
|
||||
let steam = home.join(".local/share/Steam");
|
||||
let steam = if steam.exists() {
|
||||
steam
|
||||
} else {
|
||||
home.join(".steam/steam")
|
||||
};
|
||||
for a in remote {
|
||||
if let Some(existing) = apps.get_mut(&a.id) {
|
||||
// Keep local metadata and a single stable icon, but expose both launch targets.
|
||||
if existing.remote_client.is_none() {
|
||||
existing.remote_client = Some(a.client);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
apps.entry(a.id).or_insert_with(|| {
|
||||
let icon = cached_artwork(&steam, a.id);
|
||||
App {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
kind: "steam",
|
||||
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
|
||||
icon: icon.map(|v| v.0),
|
||||
remote: true,
|
||||
remote_client: Some(a.client),
|
||||
}
|
||||
});
|
||||
}
|
||||
apps.into_values().collect()
|
||||
}
|
||||
pub fn launch_remote(id: u32, client: &str) -> Result<()> {
|
||||
remote::launch(id, client)
|
||||
}
|
||||
|
||||
pub fn home() -> Result<PathBuf> {
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
@@ -322,6 +373,43 @@ fn launch_installed(home: &Path, id: u32) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn remote_inventory_merges_launch_choices_without_duplicate_icons() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let local = vec![App {
|
||||
id: 42,
|
||||
name: "Local".into(),
|
||||
kind: "steam",
|
||||
icon: None,
|
||||
icon_fit: "contain",
|
||||
remote: false,
|
||||
remote_client: None,
|
||||
}];
|
||||
let rows = vec![
|
||||
remote::RemoteApp {
|
||||
id: 42,
|
||||
name: "Same remote".into(),
|
||||
client: "12".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Remote".into(),
|
||||
client: "12".into(),
|
||||
},
|
||||
remote::RemoteApp {
|
||||
id: 43,
|
||||
name: "Other host".into(),
|
||||
client: "13".into(),
|
||||
},
|
||||
];
|
||||
let apps = merge_remote(home.path(), local, rows);
|
||||
assert_eq!(apps.len(), 2);
|
||||
assert!(!apps[0].remote);
|
||||
assert_eq!(apps[0].name, "Local");
|
||||
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
|
||||
assert!(apps[1].remote);
|
||||
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
|
||||
}
|
||||
#[test]
|
||||
fn nested_escaped_and_invalid() {
|
||||
let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap();
|
||||
let v = object(&t, &mut 0, 0, false).unwrap();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
async function(request) {
|
||||
const store=window.appStore, remote=window.RemotePlayStore_SteamUI;
|
||||
if(!store||!remote||!Array.isArray(store.allApps)||!Array.isArray(remote.devices)) {
|
||||
if(request.mode==='list')return [];
|
||||
throw Error('Steam remote library is unavailable');
|
||||
}
|
||||
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
|
||||
const rows=[];
|
||||
for(const a of store.allApps) {
|
||||
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
|
||||
for(const r of a.remote_per_client_data||[]) {
|
||||
const client=String(r.clientid);
|
||||
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
|
||||
rows.push({id:a.appid,name:String(a.display_name||''),client});
|
||||
}
|
||||
}
|
||||
if(request.mode==='list')return rows.slice(0,10000);
|
||||
if(request.mode!=='launch'||!rows.some(r=>r.id===request.id&&r.client===request.client))return false;
|
||||
if(typeof SteamClient.Apps.StreamGame!=='function')throw Error('Steam streaming is unavailable');
|
||||
await SteamClient.Apps.StreamGame(request.id,request.client,-1);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Steam's current connected-host inventory; no account scraping or media reads.
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex, OnceLock,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub(super) struct RemoteApp {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub client: String,
|
||||
}
|
||||
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
|
||||
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
|
||||
}
|
||||
fn inventory(value: serde_json::Value) -> Result<Vec<RemoteApp>> {
|
||||
let rows: Vec<RemoteApp> = serde_json::from_value(value)?;
|
||||
ensure!(rows.len() <= 10000, "Remote library is too large");
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.id > 0 && !a.name.trim().is_empty() && a.name.len() <= 1024 && valid_client(&a.client)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn valid_client(client: &str) -> bool {
|
||||
!client.is_empty()
|
||||
&& client.len() <= 20
|
||||
&& client.bytes().all(|b| b.is_ascii_digit())
|
||||
&& client != "0"
|
||||
}
|
||||
static CACHE: OnceLock<Mutex<Option<(Instant, Vec<RemoteApp>)>>> = OnceLock::new();
|
||||
static REFRESHING: AtomicBool = AtomicBool::new(false);
|
||||
pub(super) fn cached() -> Vec<RemoteApp> {
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(None));
|
||||
let snapshot = cache.lock().unwrap().clone();
|
||||
if snapshot
|
||||
.as_ref()
|
||||
.is_none_or(|(at, _)| at.elapsed() >= Duration::from_secs(3))
|
||||
&& REFRESHING
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
std::thread::spawn(|| {
|
||||
let rows = query(serde_json::json!({"mode":"list"}))
|
||||
.and_then(inventory)
|
||||
.unwrap_or_default();
|
||||
*CACHE.get().unwrap().lock().unwrap() = Some((Instant::now(), rows));
|
||||
REFRESHING.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
snapshot
|
||||
.filter(|(at, _)| at.elapsed() < Duration::from_secs(15))
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub(super) fn launch(id: u32, client: &str) -> Result<()> {
|
||||
ensure!(id > 0 && valid_client(client), "Invalid remote Steam game");
|
||||
ensure!(
|
||||
query(serde_json::json!({"mode":"launch","id":id,"client":client}))? == true,
|
||||
"Remote game or host is no longer available"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn invalid_remote_entries_and_clients_are_rejected() {
|
||||
let rows=inventory(serde_json::json!([{ "id":1,"name":"Game","client":"123"},{"id":0,"name":"Invalid","client":"12"},{"id":2,"name":"Unsafe","client":"1);evil()"}])).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(launch(42, "0").is_err());
|
||||
assert!(launch(42, r#"1"2"#).is_err());
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
//! User-owned PTYs. Never forwards input to the core or persists terminal output.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
const CAPACITY: usize = 2 * 1024 * 1024;
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
bytes: VecDeque<u8>,
|
||||
end: u64,
|
||||
exited: bool,
|
||||
owner: Option<String>,
|
||||
}
|
||||
struct Session {
|
||||
id: String,
|
||||
name: Mutex<String>,
|
||||
output: Mutex<Output>,
|
||||
master: Mutex<Option<Box<dyn MasterPty + Send>>>,
|
||||
writer: Mutex<Option<Box<dyn Write + Send>>>,
|
||||
stop: AtomicBool,
|
||||
shell_pid: Option<u32>,
|
||||
killer: Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>,
|
||||
}
|
||||
impl Session {
|
||||
fn close(&self) {
|
||||
if self.stop.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
// The shell owns a distinct Unix session. Kill its process groups, including
|
||||
// foreground jobs and background children, before releasing the PTY.
|
||||
if let Some(pid) = self.shell_pid {
|
||||
if let Ok(entries) = std::fs::read_dir("/proc") {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(child) = entry.file_name().to_string_lossy().parse::<i32>() {
|
||||
if unsafe { libc::getsid(child) } == pid as i32 {
|
||||
unsafe {
|
||||
libc::kill(child, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.writer.lock().unwrap().take();
|
||||
self.master.lock().unwrap().take();
|
||||
let _ = self.killer.lock().unwrap().kill();
|
||||
self.output.lock().unwrap().exited = true;
|
||||
}
|
||||
fn describe(&self) -> Value {
|
||||
let out = self.output.lock().unwrap();
|
||||
json!({"id":self.id,"name":*self.name.lock().unwrap(),"exited":out.exited,"controlled":out.owner.is_some()})
|
||||
}
|
||||
}
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Terminals {
|
||||
sessions: Mutex<BTreeMap<String, Arc<Session>>>,
|
||||
}
|
||||
impl Drop for Terminals {
|
||||
fn drop(&mut self) {
|
||||
for session in self.sessions.get_mut().unwrap().values() {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn name(v: &Value) -> Result<String> {
|
||||
let n = v.as_str().unwrap_or("Terminal").trim();
|
||||
ensure!(
|
||||
!n.is_empty() && n.len() <= 128 && !n.chars().any(char::is_control),
|
||||
"Invalid terminal name"
|
||||
);
|
||||
Ok(n.into())
|
||||
}
|
||||
impl Terminals {
|
||||
pub fn api(&self, home: &Path, p: &Value) -> Result<Value> {
|
||||
ensure!(
|
||||
unsafe { libc::geteuid() } != 0,
|
||||
"Terminal must run as the Steam session user"
|
||||
);
|
||||
let op = p["operation"].as_str().unwrap_or("list");
|
||||
if op == "list" {
|
||||
return Ok(json!(self
|
||||
.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(|s| s.describe())
|
||||
.collect::<Vec<_>>()));
|
||||
}
|
||||
if op == "create" {
|
||||
let session_name = name(&p["name"])?;
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
ensure!(
|
||||
sessions.len() < 12,
|
||||
"Close a terminal before opening another"
|
||||
);
|
||||
let pair = native_pty_system().openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
let login_shell = std::fs::read_to_string("/etc/passwd")
|
||||
.ok()
|
||||
.and_then(|text| {
|
||||
text.lines().find_map(|line| {
|
||||
let fields: Vec<_> = line.split(':').collect();
|
||||
(fields.len() >= 7 && fields[2] == unsafe { libc::geteuid() }.to_string())
|
||||
.then(|| fields[6].to_string())
|
||||
})
|
||||
});
|
||||
let shell = login_shell
|
||||
.or_else(|| std::env::var("SHELL").ok())
|
||||
.filter(|s| Path::new(s).is_absolute() && Path::new(s).is_file())
|
||||
.unwrap_or_else(|| "/bin/bash".into());
|
||||
let mut cmd = CommandBuilder::new(shell);
|
||||
cmd.arg("-l");
|
||||
cmd.cwd(home);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
let mut child = pair.slave.spawn_command(cmd)?;
|
||||
drop(pair.slave);
|
||||
let mut reader = pair.master.try_clone_reader()?;
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
unsafe {
|
||||
let flags = libc::fcntl(fd, libc::F_GETFL);
|
||||
ensure!(
|
||||
flags >= 0 && libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) >= 0,
|
||||
"Cannot configure PTY"
|
||||
);
|
||||
}
|
||||
}
|
||||
let s = Arc::new(Session {
|
||||
id: hex::encode(rand::random::<[u8; 16]>()),
|
||||
name: Mutex::new(session_name),
|
||||
shell_pid: child.process_id(),
|
||||
output: Mutex::default(),
|
||||
writer: Mutex::new(Some(pair.master.take_writer()?)),
|
||||
master: Mutex::new(Some(pair.master)),
|
||||
stop: AtomicBool::new(false),
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
});
|
||||
let read_session = s.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = [0u8; 16384];
|
||||
while !read_session.stop.load(Ordering::Acquire) {
|
||||
match reader.read(&mut bytes) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let mut out = read_session.output.lock().unwrap();
|
||||
out.bytes.extend(&bytes[..n]);
|
||||
out.end += n as u64;
|
||||
while out.bytes.len() > CAPACITY {
|
||||
out.bytes.pop_front();
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(15))
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let wait_session = s.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
wait_session.output.lock().unwrap().exited = true;
|
||||
});
|
||||
let result = s.describe();
|
||||
sessions.insert(s.id.clone(), s);
|
||||
return Ok(result);
|
||||
}
|
||||
let id = p["id"].as_str().context("Missing terminal")?;
|
||||
let session = self
|
||||
.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.context("Terminal not found")?;
|
||||
match op {
|
||||
"rename" => *session.name.lock().unwrap() = name(&p["name"])?,
|
||||
"close" => {
|
||||
ensure!(p["approve"] == true, "Confirm closing the terminal");
|
||||
session.close();
|
||||
self.sessions.lock().unwrap().remove(id);
|
||||
}
|
||||
_ => anyhow::bail!("Unknown terminal operation"),
|
||||
};
|
||||
Ok(json!(true))
|
||||
}
|
||||
pub fn websocket<S: Read + Write>(
|
||||
&self,
|
||||
id: &str,
|
||||
mut ws: tungstenite::WebSocket<S>,
|
||||
valid: impl Fn() -> bool,
|
||||
) -> Result<()> {
|
||||
let session = self
|
||||
.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.context("Terminal not found")?;
|
||||
let client = hex::encode(rand::random::<[u8; 16]>());
|
||||
{
|
||||
let mut output = session.output.lock().unwrap();
|
||||
if output.owner.is_none() {
|
||||
output.owner = Some(client.clone());
|
||||
}
|
||||
}
|
||||
let mut cursor = 0u64;
|
||||
let result = (|| -> Result<()> {
|
||||
loop {
|
||||
ensure!(valid(), "Terminal login expired");
|
||||
let msg = ws.read()?;
|
||||
if msg.is_close() {
|
||||
break;
|
||||
}
|
||||
if !msg.is_text() {
|
||||
continue;
|
||||
}
|
||||
let p: Value = serde_json::from_str(msg.to_text()?)?;
|
||||
if p["claim"] == true {
|
||||
session.output.lock().unwrap().owner = Some(client.clone());
|
||||
}
|
||||
let owned = session.output.lock().unwrap().owner.as_deref() == Some(&client);
|
||||
if let Some(data) = p["input"].as_str() {
|
||||
ensure!(owned, "Terminal is read-only; take control first");
|
||||
ensure!(data.len() <= 131072, "Terminal input too large");
|
||||
let bytes = STANDARD.decode(data)?;
|
||||
let mut writer = session.writer.lock().unwrap();
|
||||
let writer = writer.as_mut().context("Terminal exited")?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
ensure!(!session.stop.load(Ordering::Acquire), "Terminal exited");
|
||||
ensure!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"Terminal input stalled; reconnect without replaying input"
|
||||
);
|
||||
match writer.write(&bytes[offset..]) {
|
||||
Ok(0) => anyhow::bail!("Terminal exited"),
|
||||
Ok(n) => offset += n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(15))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(cols), Some(rows)) = (p["cols"].as_u64(), p["rows"].as_u64()) {
|
||||
if owned {
|
||||
ensure!(
|
||||
(2..=500).contains(&cols) && (2..=300).contains(&rows),
|
||||
"Invalid terminal dimensions"
|
||||
);
|
||||
if let Some(master) = session.master.lock().unwrap().as_ref() {
|
||||
master.resize(PtySize {
|
||||
cols: cols as u16,
|
||||
rows: rows as u16,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = session.output.lock().unwrap();
|
||||
let start = out.end - out.bytes.len() as u64;
|
||||
let offset = cursor.max(start).min(out.end) - start;
|
||||
let data: Vec<u8> = out
|
||||
.bytes
|
||||
.iter()
|
||||
.skip(offset as usize)
|
||||
.take(131072)
|
||||
.copied()
|
||||
.collect();
|
||||
let reply = json!({"cursor":cursor.max(start).min(out.end)+data.len() as u64,"data":STANDARD.encode(data),"truncated":cursor<start,"writable":owned,"exited":out.exited});
|
||||
drop(out);
|
||||
cursor = reply["cursor"].as_u64().unwrap();
|
||||
ws.send(tungstenite::Message::Text(reply.to_string().into()))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
let mut out = session.output.lock().unwrap();
|
||||
if out.owner.as_deref() == Some(&client) {
|
||||
out.owner = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn output_capacity_and_names() {
|
||||
assert!(name(&json!("\n")).is_err());
|
||||
assert!(name(&json!("x".repeat(129))).is_err());
|
||||
assert_eq!(name(&json!("测试")).unwrap(), "测试");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod pty_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn reconnect_replays_bounded_output_and_single_writer_can_take_control() {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return;
|
||||
}
|
||||
let terminals = Arc::new(Terminals::default());
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let v = terminals
|
||||
.api(home.path(), &json!({"operation":"create"}))
|
||||
.unwrap();
|
||||
let id = v["id"].as_str().unwrap().to_string();
|
||||
let session = terminals.sessions.lock().unwrap()[&id].clone();
|
||||
{
|
||||
let mut out = session.output.lock().unwrap();
|
||||
out.bytes = VecDeque::from(vec![b'x'; CAPACITY]);
|
||||
out.end = CAPACITY as u64 + 123;
|
||||
}
|
||||
fn connection(
|
||||
t: Arc<Terminals>,
|
||||
id: String,
|
||||
) -> (
|
||||
tungstenite::WebSocket<std::os::unix::net::UnixStream>,
|
||||
std::thread::JoinHandle<()>,
|
||||
) {
|
||||
let (server, client) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
client
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let _ = t.websocket(
|
||||
&id,
|
||||
tungstenite::WebSocket::from_raw_socket(
|
||||
server,
|
||||
tungstenite::protocol::Role::Server,
|
||||
None,
|
||||
),
|
||||
|| true,
|
||||
);
|
||||
});
|
||||
(
|
||||
tungstenite::WebSocket::from_raw_socket(
|
||||
client,
|
||||
tungstenite::protocol::Role::Client,
|
||||
None,
|
||||
),
|
||||
handle,
|
||||
)
|
||||
}
|
||||
fn exchange(
|
||||
ws: &mut tungstenite::WebSocket<std::os::unix::net::UnixStream>,
|
||||
value: Value,
|
||||
) -> Value {
|
||||
ws.send(tungstenite::Message::Text(value.to_string().into()))
|
||||
.unwrap();
|
||||
serde_json::from_str(ws.read().unwrap().to_text().unwrap()).unwrap()
|
||||
}
|
||||
let (mut first, worker1) = connection(terminals.clone(), id.clone());
|
||||
let initial = exchange(&mut first, json!({}));
|
||||
assert_eq!(initial["writable"], true);
|
||||
assert_eq!(initial["truncated"], true);
|
||||
assert!(
|
||||
STANDARD
|
||||
.decode(initial["data"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.len()
|
||||
<= 131072
|
||||
);
|
||||
let (mut second, worker2) = connection(terminals.clone(), id.clone());
|
||||
assert_eq!(exchange(&mut second, json!({}))["writable"], false);
|
||||
assert_eq!(
|
||||
exchange(&mut second, json!({"claim":true}))["writable"],
|
||||
true
|
||||
);
|
||||
assert_eq!(exchange(&mut first, json!({}))["writable"], false);
|
||||
first.close(None).unwrap();
|
||||
second.close(None).unwrap();
|
||||
worker1.join().unwrap();
|
||||
worker2.join().unwrap();
|
||||
assert!(session.output.lock().unwrap().owner.is_none());
|
||||
terminals
|
||||
.api(
|
||||
home.path(),
|
||||
&json!({"operation":"close","id":id,"approve":true}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn shell_output_survives_without_a_browser_and_close_releases_pty() {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return;
|
||||
}
|
||||
let terminals = Terminals::default();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let v = terminals
|
||||
.api(home.path(), &json!({"operation":"create"}))
|
||||
.unwrap();
|
||||
let id = v["id"].as_str().unwrap();
|
||||
let session = terminals.sessions.lock().unwrap()[id].clone();
|
||||
session
|
||||
.writer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write_all(b"printf 'FRAMELY_%s\\n' 'PTY_TEST'\r")
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if String::from_utf8_lossy(
|
||||
&session
|
||||
.output
|
||||
.lock()
|
||||
.unwrap()
|
||||
.bytes
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.contains("FRAMELY_PTY_TEST")
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let out = session
|
||||
.output
|
||||
.lock()
|
||||
.unwrap()
|
||||
.bytes
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
assert!(String::from_utf8_lossy(&out).contains("FRAMELY_PTY_TEST"));
|
||||
assert!(terminals
|
||||
.api(home.path(), &json!({"operation":"close","id":id}))
|
||||
.is_err());
|
||||
terminals
|
||||
.api(
|
||||
home.path(),
|
||||
&json!({"operation":"close","id":id,"approve":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.master.lock().unwrap().is_none());
|
||||
assert!(session.writer.lock().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -1973,13 +1973,14 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
|
||||
|
||||
core.handle(
|
||||
"launcher.settings.save",
|
||||
json!({"primaryTrigger":true,"menuAutoClose":false,"menuTimeoutSeconds":25}),
|
||||
json!({"primaryTrigger":true,"menuAutoClose":false,"menuTimeoutSeconds":25,"prioritizeUi":false}),
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
assert!(loaded.db.launcher.primary_trigger);
|
||||
assert!(!loaded.db.launcher.menu_auto_close);
|
||||
assert_eq!(loaded.db.launcher.menu_timeout_seconds, 25);
|
||||
assert!(!loaded.db.launcher.prioritize_ui);
|
||||
let order = vec![
|
||||
"framely",
|
||||
"plugin:launch.routes",
|
||||
@@ -2004,6 +2005,11 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
|
||||
|
||||
#[test]
|
||||
fn launcher_categories_migrate_validate_and_favorites_survive_reload() {
|
||||
assert!(
|
||||
serde_json::from_value::<crate::model::LauncherSettings>(json!({}))
|
||||
.unwrap()
|
||||
.prioritize_ui
|
||||
);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let state = root.path().join("state");
|
||||
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
@@ -2071,3 +2077,49 @@ fn launcher_trigger_defaults_preserve_explicit_preferences() {
|
||||
serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap();
|
||||
assert!(!restored.primary_trigger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_gaze_calibration_validates_persists_and_clears() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let state = root.path().join("state");
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let mut core = accepted_service(&state, uid).unwrap();
|
||||
assert!(core
|
||||
.handle("launcher.gaze.calibration.get", json!({}))
|
||||
.unwrap()
|
||||
.is_null());
|
||||
let calibration = json!({"space":"headAngles","matrix":[1.1,0.02,-0.03,0.01,0.95,0.02],"width":1440,"height":800,"error":0.01,"maxError":0.02});
|
||||
let mut legacy = calibration.clone();
|
||||
legacy.as_object_mut().unwrap().remove("space");
|
||||
let legacy_model: crate::model::GazeCalibration =
|
||||
serde_json::from_value(legacy.clone()).unwrap();
|
||||
assert!(legacy_model.validate().is_err());
|
||||
assert!(core
|
||||
.handle("launcher.gaze.calibration.save", legacy)
|
||||
.is_err());
|
||||
core.handle("launcher.gaze.calibration.save", calibration.clone())
|
||||
.unwrap();
|
||||
for bad in [
|
||||
json!({"space":"headAngles","matrix":[-1,0,0,0,1,0],"width":1440,"height":800,"error":0.01,"maxError":0.02}),
|
||||
json!({"space":"headAngles","matrix":[1,0,0,0,1,0],"width":1440,"height":800,"error":0.08,"maxError":0.09}),
|
||||
] {
|
||||
assert!(core.handle("launcher.gaze.calibration.save", bad).is_err());
|
||||
}
|
||||
let mut loaded = Service::load(&state, uid).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
.handle("launcher.gaze.calibration.get", json!({}))
|
||||
.unwrap(),
|
||||
calibration
|
||||
);
|
||||
loaded.handle("launcher.settings.save", json!({})).unwrap();
|
||||
assert!(loaded.db.gaze_calibration.is_some());
|
||||
loaded
|
||||
.handle("launcher.gaze.calibration.save", Value::Null)
|
||||
.unwrap();
|
||||
assert!(Service::load(&state, uid)
|
||||
.unwrap()
|
||||
.db
|
||||
.gaze_calibration
|
||||
.is_none());
|
||||
}
|
||||
+91
-12
@@ -1,12 +1,13 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
os::unix::fs::FileExt,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub const CHUNK: usize = 512 * 1024;
|
||||
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Uploads(Arc<Mutex<Option<Upload>>>);
|
||||
struct Upload {
|
||||
@@ -15,6 +16,7 @@ struct Upload {
|
||||
file: File,
|
||||
total: u64,
|
||||
received: u64,
|
||||
ranges: BTreeMap<u64, u64>,
|
||||
touched: Instant,
|
||||
}
|
||||
impl Uploads {
|
||||
@@ -36,13 +38,17 @@ impl Uploads {
|
||||
ensure!(slot.is_none(), "An upload is already running");
|
||||
let id = hex::encode(rand::random::<[u8; 24]>());
|
||||
let package = crate::package::Staged::create_in(parent)?;
|
||||
let file = OpenOptions::new().write(true).open(&package.path)?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&package.path)?;
|
||||
*slot = Some(Upload {
|
||||
id: id.clone(),
|
||||
package,
|
||||
file,
|
||||
total,
|
||||
received: 0,
|
||||
ranges: BTreeMap::new(),
|
||||
touched: Instant::now(),
|
||||
});
|
||||
Ok(json!({"upload":id,"chunkSize":CHUNK}))
|
||||
@@ -56,20 +62,41 @@ impl Uploads {
|
||||
Self::expire(&mut slot);
|
||||
let u = slot.as_mut().context("Upload expired")?;
|
||||
ensure!(u.id == id, "Unknown upload");
|
||||
ensure!(offset == u.received, "Unexpected upload offset");
|
||||
ensure!(
|
||||
bytes.len() as u64 <= u.total - u.received,
|
||||
offset <= u.total && bytes.len() as u64 <= u.total - offset,
|
||||
"Upload exceeds declared size"
|
||||
);
|
||||
// A failed write invalidates the partial file rather than allowing retry at a stale offset.
|
||||
if let Err(error) = u.file.write_all(bytes) {
|
||||
*slot = None;
|
||||
return Err(error.into());
|
||||
let end = offset + bytes.len() as u64;
|
||||
if u.ranges.get(&offset) == Some(&end) {
|
||||
// A lost acknowledgement may be retried, but cannot change accepted bytes.
|
||||
let mut existing = vec![0; bytes.len()];
|
||||
u.file.read_exact_at(&mut existing, offset)?;
|
||||
ensure!(existing == bytes, "Upload retry content mismatch");
|
||||
} else {
|
||||
ensure!(
|
||||
u.ranges
|
||||
.range(..=offset)
|
||||
.next_back()
|
||||
.is_none_or(|(_, e)| *e <= offset)
|
||||
&& u.ranges
|
||||
.range(offset..)
|
||||
.next()
|
||||
.is_none_or(|(s, _)| *s >= end),
|
||||
"Overlapping upload chunk"
|
||||
);
|
||||
// Serialize writes with cancellation so an aborted upload cannot keep writing.
|
||||
if let Err(error) = u.file.write_all_at(bytes, offset) {
|
||||
*slot = None;
|
||||
return Err(error.into());
|
||||
}
|
||||
u.ranges.insert(offset, end);
|
||||
u.received += bytes.len() as u64;
|
||||
}
|
||||
u.received += bytes.len() as u64;
|
||||
u.touched = Instant::now();
|
||||
Ok(json!({"received":u.received,"total":u.total}))
|
||||
// received acknowledges this chunk's end (also compatible with sequential clients).
|
||||
Ok(json!({"received":end,"uploadedBytes":u.received,"total":u.total}))
|
||||
}
|
||||
|
||||
pub fn abort(&self, id: &str) -> Result<Value> {
|
||||
let mut slot = self.0.lock().unwrap();
|
||||
if slot.as_ref().is_some_and(|u| u.id == id) {
|
||||
@@ -115,7 +142,7 @@ mod tests {
|
||||
.to_owned();
|
||||
assert!(uploads.start(1).is_err());
|
||||
assert!(uploads.append("wrong", 0, b"a").is_err());
|
||||
assert!(uploads.append(&id, 1, b"a").is_err());
|
||||
assert!(uploads.append(&id, 3, b"a").is_err());
|
||||
assert!(uploads.append(&id, 0, b"abcd").is_err());
|
||||
uploads.append(&id, 0, b"ab").unwrap();
|
||||
assert!(uploads.take(&id).is_err());
|
||||
@@ -128,6 +155,58 @@ mod tests {
|
||||
assert!(uploads.take(&id).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn out_of_order_chunks_retries_and_holes() {
|
||||
let uploads = Uploads::default();
|
||||
let start = uploads.start(9).unwrap();
|
||||
let id = start["upload"].as_str().unwrap();
|
||||
let ack = uploads.append(id, 6, b"ghi").unwrap();
|
||||
assert_eq!(ack["received"], 9);
|
||||
assert_eq!(ack["uploadedBytes"], 3);
|
||||
assert!(uploads.take(id).is_err());
|
||||
assert_eq!(uploads.append(id, 6, b"ghi").unwrap()["uploadedBytes"], 3);
|
||||
assert!(uploads.append(id, 6, b"xxx").is_err());
|
||||
assert!(uploads.append(id, 5, b"fg").is_err());
|
||||
assert!(uploads.append(id, u64::MAX, b"x").is_err());
|
||||
uploads.append(id, 0, b"abc").unwrap();
|
||||
assert!(uploads.append(id, 1, b"b").is_err());
|
||||
assert!(uploads.append(id, 0, b"ab").is_err());
|
||||
assert!(uploads.take(id).is_err());
|
||||
uploads.append(id, 3, b"def").unwrap();
|
||||
let staged = uploads.take(id).unwrap();
|
||||
assert_eq!(fs::read(&staged.path).unwrap(), b"abcdefghi");
|
||||
assert_eq!(staged.hash, crate::package::digest(b"abcdefghi"));
|
||||
}
|
||||
#[test]
|
||||
fn simultaneous_chunks_produce_a_complete_file() {
|
||||
let uploads = Uploads::default();
|
||||
let start = uploads.start(4 * CHUNK as u64).unwrap();
|
||||
let id = start["upload"].as_str().unwrap().to_owned();
|
||||
let barrier = Arc::new(std::sync::Barrier::new(4));
|
||||
let workers: Vec<_> = (0..4)
|
||||
.map(|index| {
|
||||
let uploads = uploads.clone();
|
||||
let id = id.clone();
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
uploads
|
||||
.append(&id, (index * CHUNK) as u64, &vec![index as u8; CHUNK])
|
||||
.unwrap();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
let staged = uploads.take(&id).unwrap();
|
||||
let bytes = fs::read(&staged.path).unwrap();
|
||||
for index in 0..4 {
|
||||
assert!(bytes[index * CHUNK..(index + 1) * CHUNK]
|
||||
.iter()
|
||||
.all(|b| *b == index as u8));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn persistent_uploads_are_private_and_removed_on_cancel_or_drop() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let uploads = Uploads::default();
|
||||
|
||||
@@ -1,38 +1,96 @@
|
||||
import React from 'react';import{createRoot}from'react-dom/client';
|
||||
import{FrameFilePicker}from'../ui/src/file-picker';
|
||||
import{ApkManager}from'../ui/src/apk-manager';import{configureLanguage,t}from'../ui/src/i18n';import{Brand,Icon}from'../ui/src/icons';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(-500));};
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(-200)+' calls='+JSON.stringify(calls.slice(-4))+' lifecycle='+autoStopContainer+'/'+stopContainerOnClose+' disabled='+document.querySelector('[aria-label="Stop container after app exits"]')?.outerHTML);};
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),steamLaunch:true,steamRegistered:true,installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let uninstallError:string|null=null;let counter=0,fail=false,gamepadEnabled=false,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const p=JSON.parse(String(init?.body));calls.push({method:String(url),params:p});let result:any=true;if(String(url).endsWith('/list'))result={path:'/home/steamos/Downloads',parent:'/home/steamos',roots:[],entries:[{name:'device.apk',path:'/home/steamos/Downloads/device.apk',directory:false,size:1024}],total:1,offset:0,skipped:0};else if(String(url).endsWith('/apk-inspect')){const id='job-'+counter++;jobs.set(id,{id,kind:'apk',phase:'done',result:{ticket:'local-review-ticket',metadata:meta('com.example.local','Local APK'),bytes:1024},received:0});result={job:id};}return new Response(JSON.stringify({result}));}if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='diagnostics.export')result={name:'logs.zip',path:'/home/steamos/Downloads/logs.zip'};
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true};
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,autoStopContainer,stopContainerOnClose};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{const app=apps.find(a=>a.id===params.app);if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/></main></div></div>);}
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/><FrameFilePicker/></main></div></div>);}
|
||||
const button=(text:string,scope:ParentNode=document)=>Array.from(scope.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===text)??(()=>{throw Error('Missing button '+text)})();
|
||||
async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIEW_INSTALL_'+name);await wait(180);}
|
||||
(async()=>{
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();if(Math.abs(manager.width-(content.width-64))>2&&innerWidth>1000)throw Error('Manager width does not fill content');const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left)throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
configureLanguage('en-US',[]);render();await until(()=>document.querySelectorAll('.apk-row').length===2);const content=document.querySelector('.content')!.getBoundingClientRect(),manager=document.querySelector('.apk-manager')!.getBoundingClientRect();const padding=getComputedStyle(document.querySelector('.content')!);if(Math.abs(manager.width-(document.querySelector('.content')!.clientWidth-parseFloat(padding.paddingLeft)-parseFloat(padding.paddingRight)))>2)throw Error(`Manager width does not fill content: ${manager.width}/${content.width}, ${padding.paddingLeft}/${padding.paddingRight}`);const search=document.querySelector<HTMLInputElement>('.apk-filters input')!.getBoundingClientRect(),filter=document.querySelector<HTMLButtonElement>('.apk-filters .framely-select>button')!.getBoundingClientRect();if(window.innerWidth>700?(Math.abs(search.y-filter.y)>1||Math.abs(search.height-filter.height)>1||search.right>=filter.left):(filter.top<search.bottom||Math.abs(filter.width-search.width)>1))throw Error('APK search/filter controls are not aligned');await snapshot('APK_EN');
|
||||
if(document.querySelector('[role=switch]'))throw Error('Global settings leaked onto app list');button('Settings',document.querySelector('.apk-tabs')!).click();await until(()=>!!document.querySelector('[role=switch][aria-label="Stop container after app exits"]'));
|
||||
const automatic=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Stop container after app exits"]')!;
|
||||
const closePolicy=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Stop container when closing its window"]')!;
|
||||
if(automatic().getAttribute('aria-checked')!=='false'||closePolicy().getAttribute('aria-checked')!=='true')throw Error('Incorrect container lifecycle defaults');
|
||||
automatic().click();await until(()=>automatic().getAttribute('aria-checked')==='true'&&!automatic().disabled);closePolicy().click();await until(()=>closePolicy().getAttribute('aria-checked')==='false'&&!closePolicy().disabled);
|
||||
automatic().click();await until(()=>automatic().getAttribute('aria-checked')==='false'&&!automatic().disabled);closePolicy().click();await until(()=>closePolicy().getAttribute('aria-checked')==='true'&&!closePolicy().disabled);
|
||||
const gamepad=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Gamepad input"]')!;
|
||||
if(gamepad().getAttribute('aria-checked')!=='false')throw Error('Gamepad input does not default off');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='true'&&!gamepad().disabled);if(!gamepadEnabled)throw Error('Gamepad switch was not persisted');
|
||||
gamepad().click();await until(()=>gamepad().getAttribute('aria-checked')==='false'&&!gamepad().disabled);if(gamepadEnabled)throw Error('Gamepad input did not turn off');
|
||||
// Container startup and shutdown are not data deletion operations.
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器内全部数据将永久删除。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));if(document.querySelector('[aria-label="Delete app data"]')!.getAttribute('aria-checked')!=='false')throw Error('Uninstall defaults to data deletion');if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!.click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));button('Confirm',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));if(calls.find(c=>c.params.operation==='uninstall')!.params.purge)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));
|
||||
const purgeSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!;
|
||||
const deleteSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete container"]')!;
|
||||
if(purgeSwitch().getAttribute('aria-checked')!=='false'||deleteSwitch().getAttribute('aria-checked')!=='false'||!deleteSwitch().disabled)throw Error('Uninstall defaults to data deletion');
|
||||
if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Data-preserving uninstall warns of permanent data deletion');
|
||||
await snapshot('APK_UNINSTALL_KEEP');purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
if(deleteSwitch().disabled)throw Error('Exclusive container deletion is disabled');await snapshot('APK_UNINSTALL_DELETE');
|
||||
deleteSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='false');if(purgeSwitch().getAttribute('aria-checked')!=='true')throw Error('Container opt-out cleared purge');
|
||||
purgeSwitch().click();await until(()=>!document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')));
|
||||
button('Uninstall',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'));await until(()=>!document.querySelector('progress'));
|
||||
const uninstall=calls.find(c=>c.params.operation==='uninstall')!.params;if(uninstall.purge||uninstall.deleteContainer)throw Error('Uninstall purged by default');await until(()=>Array.from(document.querySelectorAll('.apk-dialog button')).some(b=>b.textContent==='Done'));button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
|
||||
button('Data cleanup').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);if(document.querySelector('[role=switch][aria-checked=true]'))throw Error('Sensitive data selected by default');if(!document.querySelector<HTMLButtonElement>('[role=switch][aria-label*="com.example.orphan"]')!.disabled)throw Error('Unknown data can be deleted');await snapshot('APK_CLEANUP_EN');
|
||||
document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Select Notes"]')!.click();await until(()=>!button('Clean selected').disabled);button('Clean selected').click();await until(()=>document.querySelector('.apk-dialog'));button('Cancel',document.querySelector('.apk-dialog')!).click();if(calls.some(c=>c.params.operation==='cleanup'))throw Error('Cancel performed cleanup');
|
||||
apps[0].installed=true;await wait();configureLanguage('zh-CN',[]);console.log('APK_LOCALE '+t('APK 管理'));root.render(<div/>);await wait();configureLanguage('zh-CN',[]);render();await until(()=>document.querySelector('.apk-manager h1')?.textContent==='APK 管理');await until(()=>!document.querySelector('.empty'));await snapshot('APK_ZH');button('数据清理').click();await until(()=>document.querySelectorAll('.apk-cleanup-row').length===3);await snapshot('APK_CLEANUP_ZH');button('应用').click();
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const targets=[...document.querySelectorAll('[role=option]')].map(b=>b.textContent);if(!targets.includes('dev')||targets.includes('framely-wolvic'))throw Error('Install targets ignore backend shared-mount policy');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="目标容器"]')!.click();button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');
|
||||
const file=new File(['test'],'sample.apk',{type:'application/vnd.android.package-archive'}),transfer=new DataTransfer();transfer.items.add(file);const input=document.querySelector<HTMLInputElement>('input[type=file]')!;input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));if(calls.find(c=>c.method==='apk.upload.start')?.params.size!==4)throw Error('APK upload transport mismatch');await snapshot('APK_REVIEW_ZH');document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));if(document.querySelector('.apk-dialog button[aria-label="目标容器"]')||!document.querySelector('.apk-install-location')!.textContent!.includes(t('创建独立容器')))throw Error('New APK can select a shared container');button('取消',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.method==='apk.review.drop'));if(calls.some(c=>c.params.operation==='install'))throw Error('Cancelled review installed APK');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));await until(()=>document.querySelector('[aria-label="安装 APK"] .apk-meta'));document.querySelector<HTMLButtonElement>('.apk-dialog button[aria-label="显示方式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));button('VR 应用',document.querySelector('[role=listbox]')!).click();await until(()=>document.querySelector('.apk-dialog button[aria-label="显示方式"]')!.textContent?.includes('VR 应用'));button('确认安装',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='install'));if(calls.find(c=>c.params.operation==='install')!.params.showWindow!==false)throw Error('VR display mode was not submitted');await until(()=>!document.querySelector('.apk-dialog'));
|
||||
configureLanguage('en-US',[]);apps.push({id:'unknown/com.example.unknown',context:'unknown',metadata:meta('com.example.unknown','Unknown app'),installed:false,stateKnown:false,managed:false,size:10,pending:'Container unavailable; data has not been deleted'},{id:'steam/com.example.steam',context:'steam',metadata:meta('com.example.steam','Steam app'),installed:true,steam:true,size:10});root.render(<div/>);await wait();render();await until(()=>[...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Unknown app')));if([...document.querySelectorAll('.apk-row')].some(r=>r.textContent?.includes('Steam app')))throw Error('Steam app exposed in side-loading app list');const unknown=[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Unknown app'))!;if(!unknown.textContent?.includes(t('安装状态未知'))||[...unknown.querySelectorAll('button')].some(b=>b.textContent==='Open'))throw Error('Unknown state presented as launchable or uninstalled');button('Manage',unknown).click();await until(()=>document.querySelector('.apk-dialog'));if([...document.querySelectorAll('.apk-dialog button')].some(b=>b.textContent==='Reinstall APK'))throw Error('Unknown app offers reinstallation');if(!button(t('清除保留数据'),document.querySelector('.apk-dialog')!).disabled)throw Error('Unknown app can delete retained data');button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
// Frame-local selection cancels CEF's File result and starts inspection by path.
|
||||
button('Install APK').click();window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:{id:'native-cancel',accept:['.apk'],multiple:false,directory:false}}));await until(()=>!!document.querySelector('.frame-files'));button('Cancel',document.querySelector('.frame-files')!).click();await until(()=>!document.querySelector('.frame-files'));if(calls.some(c=>c.method==='/host/files/apk-inspect'))throw Error('Cancelling local picker inspected APK');
|
||||
const uploadsBefore=calls.filter(c=>c.method==='apk.upload.start').length;
|
||||
button('Install APK').click();window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:{id:'native-local',accept:['.apk'],multiple:false,directory:false}}));
|
||||
await until(()=>!!document.querySelector('.frame-file-row'));document.querySelector<HTMLButtonElement>('.frame-file-row')!.click();await until(()=>!document.querySelector<HTMLButtonElement>('.frame-files-confirm')!.disabled);button('Select',document.querySelector('.frame-files')!).click();
|
||||
await until(()=>document.querySelector('.apk-dialog')?.textContent?.includes('Local APK'));
|
||||
if(calls.filter(c=>c.method==='apk.upload.start').length!==uploadsBefore)throw Error('Frame local APK was uploaded');
|
||||
const local=calls.find(c=>c.method==='/host/files/apk-inspect');if(local?.params.paths[0]!=='/home/steamos/Downloads/device.apk')throw Error('Local APK path not inspected');
|
||||
if(calls.find(c=>c.method==='/host/files/complete'&&c.params.id==='native-local')?.params.paths.length!==0)throw Error('Local APK returned a browser File');
|
||||
button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));
|
||||
document.querySelector<HTMLDetailsElement>('.apk-advanced')!.open=true;
|
||||
const steamSwitch=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Launch through Steam"]')!;
|
||||
if(steamSwitch().getAttribute('aria-checked')!=='true')throw Error('Steam launch must default on');
|
||||
steamSwitch().click();await until(()=>steamSwitch().getAttribute('aria-checked')==='false'&&!steamSwitch().disabled);
|
||||
steamSwitch().click();await until(()=>steamSwitch().getAttribute('aria-checked')==='true'&&!steamSwitch().disabled);
|
||||
if(calls.filter(c=>c.method==='apk.operation.start'&&c.params.operation==='steam.settings').length!==2)throw Error('Steam launch switch did not use the APK operation API');
|
||||
button('Done',document.querySelector('.apk-dialog')!).click();
|
||||
// Shared containers include retained apps, so purge must never select container deletion.
|
||||
apps.push({...apps[0],id:'framely-wolvic/com.example.other',metadata:meta('com.example.other','Other retained app'),installed:false});
|
||||
root.render(<div/>);await wait();render();await until(()=>document.querySelectorAll('.apk-row').length>=3);
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall'));
|
||||
purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='true');if(!deleteSwitch().disabled||deleteSwitch().getAttribute('aria-checked')!=='false')throw Error('Shared container selected for deletion');button('Cancel',document.querySelector('.apk-dialog')!).click();await wait();button('Done',document.querySelector('.apk-dialog')!).click();apps.pop();
|
||||
root.render(<div/>);await wait();render();await until(()=>document.querySelectorAll('.apk-row').length>=3);
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall'));purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
deleteSwitch().click();purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='false');
|
||||
uninstallError='Android PackageManager did not become ready; export logs and retry.';
|
||||
button('Uninstall',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall .error'));
|
||||
if(!apps[0].installed)throw Error('Failed uninstall removed app');
|
||||
if(!document.querySelector('.apk-uninstall .error')?.textContent?.includes('PackageManager'))throw Error('Missing specific readiness error');
|
||||
button('Export logs',document.querySelector('.apk-dialog')!).click();await until(()=>[...document.querySelectorAll('.apk-uninstall [role=status]')].some(e=>e.textContent?.includes('/home/steamos/Downloads/logs.zip')));
|
||||
if(!calls.some(c=>c.method==='diagnostics.export'))throw Error('Uninstall diagnostics not exported');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await wait();await until(()=>!document.querySelector('progress')&&!!document.querySelector('.apk-uninstall .error')&&!button('Retry',document.querySelector('.apk-dialog')!).disabled);
|
||||
uninstallError=null;purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'&&c.params.deleteContainer));if(!calls.find(c=>c.params.operation==='uninstall'&&c.params.deleteContainer)!.params.purge)throw Error('Container deletion omitted purge');
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -42,7 +42,8 @@ int main(int argc,char** argv){
|
||||
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
|
||||
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
|
||||
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>240)rounded_paint=true;}};
|
||||
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
|
||||
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
|
||||
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
|
||||
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
|
||||
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "entry_hold.h"
|
||||
#include "paint_buffer.h"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
int main(){
|
||||
using A=EntryHold::Action;
|
||||
using namespace std::chrono;
|
||||
const auto start=EntryHold::Clock::time_point{}+seconds(10);
|
||||
// All mouse buttons and runtime-supplied input (without a tracked device).
|
||||
for(unsigned device:{1u,2u,~0u})for(unsigned button:{1u,2u,4u}){
|
||||
EntryHold hold;
|
||||
assert(hold.event(true,device,button,true,true,start)==A::Idle);
|
||||
assert(hold.event(false,device,button,true,true,start+milliseconds(100))==A::Launcher);
|
||||
assert(hold.event(false,device,button,true,true,start+milliseconds(110))==A::Idle);
|
||||
}
|
||||
EntryHold hold;
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.progress(start+milliseconds(199))==0);
|
||||
assert(hold.tick(start+milliseconds(200),true,true)==A::Idle);
|
||||
assert(hold.progress(start+milliseconds(200))>0&&hold.progress(start+milliseconds(200))<.01f);
|
||||
assert(std::abs(hold.progress(start+milliseconds(400))-.5f)<.001f);
|
||||
assert(hold.tick(start+milliseconds(599),true,true)==A::Idle);
|
||||
assert(hold.progress(start+milliseconds(599))<1);
|
||||
assert(hold.tick(start+milliseconds(600),true,true)==A::QuickPanel);
|
||||
assert(hold.tick(start+milliseconds(900),true,true)==A::Idle);
|
||||
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
|
||||
assert(hold.progress(start+seconds(1))==0);
|
||||
// Crossing the threshold between frames still resolves exclusively as a hold.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,1,1,true,true,start+milliseconds(600))==A::QuickPanel);
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,2,1,true,true,start+milliseconds(100))==A::Idle);
|
||||
assert(hold.event(false,1,2,true,true,start+milliseconds(110))==A::Idle);
|
||||
assert(hold.event(false,1,1,true,true,start+milliseconds(120))==A::Launcher);
|
||||
// A release with no tracked device must not leave an old progress ring held.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,~0u,1,true,true,start+milliseconds(80))==A::Launcher);
|
||||
assert(!hold.pressed&&hold.progress(start+milliseconds(100))==0);
|
||||
// Small jitter does not restart the timer; a sustained leave or loss cancels.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
hold.tick(start+milliseconds(480),true,true);
|
||||
assert(hold.tick(start+milliseconds(550),true,false)==A::Idle);
|
||||
assert(hold.tick(start+milliseconds(610),true,true)==A::QuickPanel);
|
||||
hold.cancel();hold.event(true,1,1,true,true,start);
|
||||
hold.tick(start+milliseconds(151),true,false);
|
||||
assert(!hold.pressed);
|
||||
assert(hold.event(false,1,1,true,true,start+seconds(1))==A::Idle);
|
||||
for(bool inside:{false,true}){
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.tick(start+milliseconds(600),false,inside)==A::Idle);
|
||||
assert(!hold.pressed);
|
||||
}
|
||||
hold.event(true,1,1,true,false,start);
|
||||
assert(hold.tick(start+seconds(1),true,true)==A::Idle);
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.event(false,1,1,true,false,start+milliseconds(100))==A::Idle);
|
||||
// A short click with a late release is judged by event time, not dequeue time.
|
||||
const auto received=start+seconds(1);
|
||||
hold.event(true,1,1,true,true,entry_event_time(received,1.f),received);
|
||||
assert(hold.tick(received,true,true)==A::Idle);
|
||||
assert(hold.progress(received+milliseconds(199))==0);
|
||||
assert(hold.event(false,1,1,true,true,entry_event_time(received,.92f),received)==A::Launcher);
|
||||
// A missing overlay release cannot leave a delayed quick-panel activation.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
assert(hold.released(true,true)==A::Launcher);
|
||||
assert(hold.tick(start+seconds(1),true,true)==A::Idle);
|
||||
// A fresh down after a lost release starts a new gesture, never the old timer.
|
||||
hold.event(true,1,1,true,true,start);
|
||||
hold.event(true,1,1,true,true,start+seconds(1));
|
||||
assert(hold.progress(start+seconds(1)+milliseconds(80))==0);
|
||||
assert(hold.event(false,1,1,true,true,start+seconds(1)+milliseconds(80))==A::Launcher);
|
||||
// A real long hold remains a single long action, including delayed dequeue.
|
||||
hold.event(true,1,1,true,true,entry_event_time(received,1.f),received);
|
||||
assert(hold.event(false,1,1,true,true,entry_event_time(received,.3f),received)==A::QuickPanel);
|
||||
assert(entry_event_time(received,-1.f)==received);
|
||||
assert(entry_event_time(received,NAN)==received);
|
||||
// The progress follows the rounded square's border, leaving the logo intact.
|
||||
std::vector<uint8_t> pixels(128*128*4,23),original=pixels;
|
||||
entry_progress_border(pixels,0);assert(pixels==original);
|
||||
entry_progress_border(pixels,.5f);assert(pixels!=original);
|
||||
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
|
||||
int i=(y*128+x)*4;assert(pixels[i+3]==original[i+3]);
|
||||
if(std::abs(x-63.5f)<58&&std::abs(y-63.5f)<58)for(int c=0;c<4;c++)assert(pixels[i+c]==original[i+c]);
|
||||
}
|
||||
const int top=(2*128+64)*4;
|
||||
assert(pixels[top]==147&&pixels[top+1]==197&&pixels[top+2]==237);
|
||||
assert(pixels[(64*128+125)*4]>original[(64*128+125)*4]);
|
||||
assert(pixels[(4*128+123)*4]>original[(4*128+123)*4]); // rounded corner
|
||||
assert(pixels[(5*128+64)*4]==original[(5*128+64)*4]); // no circular ring
|
||||
struct Rect{int x,y,width,height;};std::vector<uint8_t> upload;
|
||||
merge_paint(upload,pixels.data(),128,128,0,static_cast<const Rect*>(nullptr),true);
|
||||
assert(upload[((127-2)*128+64)*4]==pixels[top]);
|
||||
assert(pixels[top]>pixels[(2*128+63)*4]);
|
||||
auto partial=pixels;entry_progress_border(pixels,1);assert(pixels!=partial);
|
||||
std::cout<<"entry_hold: passed\n";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Read-only OpenVR application identity assertions for gamepad_identity.py.
|
||||
#include "openvr.h"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3 || argc % 2 != 1) return 2;
|
||||
vr::EVRInitError error;
|
||||
vr::VR_Init(&error, vr::VRApplication_Overlay);
|
||||
if (error) return 2;
|
||||
bool ok = true;
|
||||
for (int i = 1; i < argc; i += 2) {
|
||||
auto actual = vr::VRApplications()->GetApplicationProcessId(argv[i]);
|
||||
auto expected = std::strtoul(argv[i + 1], nullptr, 10);
|
||||
if (actual != expected) {
|
||||
std::fprintf(stderr, "Application identity mismatch: expected %lu, got %u\n", expected, actual);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
vr::VR_Shutdown();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame integration: concurrent pads keep independent SteamVR identities.
|
||||
|
||||
Usage: gamepad_identity.py HELPER ACTION_MANIFEST IDENTITY_PROBE
|
||||
Both helpers remain disabled. No physical input is read or forwarded.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def check(helper, actions, probe):
|
||||
children = []
|
||||
nodes = []
|
||||
keys = []
|
||||
with tempfile.TemporaryDirectory(prefix='framely-pad-identity-') as directory:
|
||||
try:
|
||||
for n in range(2):
|
||||
key = 'framely.gamepad.' + uuid.uuid4().hex
|
||||
path = pathlib.Path(directory) / f'{n}.vrmanifest'
|
||||
path.write_text(json.dumps({'applications': [{
|
||||
'app_key': key, 'launch_type': 'binary', 'is_self_identified': True,
|
||||
'binary_path_linux_arm': helper, 'binary_path_linux': helper, 'action_manifest_path': actions,
|
||||
'strings': {'en_us': {'name': 'Framely neutral pad test'}},
|
||||
}]}))
|
||||
child = subprocess.Popen([helper, actions, str(path), key],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
children.append(child)
|
||||
keys.append(key)
|
||||
assert select.select([child.stdout], [], [], 8)[0], 'Readiness timeout'
|
||||
node = pathlib.Path(child.stdout.readline().decode().strip())
|
||||
assert node.parent == pathlib.Path('/dev/input') and node.is_char_device(), child.stderr.read().decode() if child.poll() is not None else 'Missing node'
|
||||
nodes.append(node)
|
||||
subprocess.run([probe, keys[0], str(children[0].pid), keys[1], str(children[1].pid)], check=True)
|
||||
children[0].stdin.close()
|
||||
assert children[0].wait(timeout=5) == 0
|
||||
subprocess.run([probe, keys[0], '0', keys[1], str(children[1].pid)], check=True)
|
||||
children[1].stdin.close()
|
||||
assert children[1].wait(timeout=5) == 0
|
||||
assert all(not node.exists() for node in nodes)
|
||||
print('PASS: independent identities survive another pad closing; EOF removes both devices')
|
||||
finally:
|
||||
for child in children:
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
check(*sys.argv[1:])
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame integration check: worker exit retains the pad, pipe EOF removes it.
|
||||
|
||||
Run as the Steam user with SteamVR awake. The helper remains disabled throughout;
|
||||
this test creates its own neutral virtual device and never reads physical input.
|
||||
Usage: python3 tests/gamepad_lifetime.py HELPER ACTION_MANIFEST
|
||||
"""
|
||||
import pathlib
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def check(helper, manifest):
|
||||
state = {}
|
||||
|
||||
def worker():
|
||||
try:
|
||||
child = subprocess.Popen(
|
||||
[helper, manifest], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
state['child'] = child
|
||||
if not select.select([child.stdout], [], [], 8)[0]:
|
||||
raise RuntimeError('Virtual gamepad readiness timed out')
|
||||
state['node'] = pathlib.Path(child.stdout.readline().decode().strip())
|
||||
except Exception as error:
|
||||
state['error'] = error
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
thread.join(10)
|
||||
child = state.get('child')
|
||||
try:
|
||||
assert not thread.is_alive(), 'Preparation worker did not finish'
|
||||
if 'error' in state:
|
||||
raise state['error']
|
||||
node = state['node']
|
||||
assert node.parent == pathlib.Path('/dev/input') and node.name.startswith('event')
|
||||
time.sleep(2)
|
||||
assert child.poll() is None, 'Worker exit destroyed the virtual gamepad'
|
||||
assert node.is_char_device(), 'Virtual gamepad node disappeared'
|
||||
child.stdin.close()
|
||||
assert child.wait(timeout=5) == 0, 'Control pipe EOF did not cleanly stop the helper'
|
||||
assert not node.exists(), 'Virtual gamepad survived control pipe EOF'
|
||||
print('PASS: worker exit preserves the gamepad; session pipe close removes it')
|
||||
finally:
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit(__doc__)
|
||||
check(*sys.argv[1:])
|
||||
@@ -0,0 +1,17 @@
|
||||
#include "gamepad_mapping.h"
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
int main(){
|
||||
// Android Vendor_045e_Product_028e.kl scan codes, independent of Linux aliases.
|
||||
const int androidXbox[]={304,305,307,308,310,311,314,315,317,318};
|
||||
static_assert(sizeof(keys)==sizeof(androidXbox));
|
||||
for(int i=0;i<10;i++)assert(keys[i]==androidXbox[i]);
|
||||
// The custom host identity must retain Android's expected ABXY/axis layout.
|
||||
std::ifstream file("native/input/gamepad/Vendor_0001_Product_f001.kl");assert(file);std::map<int,std::string> entries;std::string line;
|
||||
while(std::getline(file,line)){std::istringstream row(line);std::string kind,name;int code;if(row>>kind>>code>>name&&kind=="key")entries[code]=name;}
|
||||
const char* names[]={"BUTTON_A","BUTTON_B","BUTTON_X","BUTTON_Y","BUTTON_L1","BUTTON_R1","BUTTON_SELECT","BUTTON_START","BUTTON_THUMBL","BUTTON_THUMBR"};
|
||||
for(int i=0;i<10;i++)assert(entries[keys[i]]==names[i]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "gamepad_steam.h"
|
||||
#include "gamepad_feedback.h"
|
||||
#include <cassert>
|
||||
#include <sstream>
|
||||
int main(){
|
||||
std::istringstream info("[slot 0]\nname=Steam Frame Controllers\nVID=0x28de\nPID=0x11e0\n[slot 1]\nVID=0x0001\nPID=0xf001\n[slot 2]\nVID=0x045e\nPID=0x028e\n");
|
||||
auto rows=read_steam_pad_info(info);assert(allowed_steam_slot(rows,0));assert(!allowed_steam_slot(rows,1));assert(allowed_steam_slot(rows,2));assert(!allowed_steam_slot(rows,3));
|
||||
std::istringstream missing("[slot 0]\nVID=0x28de\n");assert(!allowed_steam_slot(read_steam_pad_info(missing),0));
|
||||
std::istringstream invalid("[slot 99]\nVID=0x28de\nPID=0x11e0\n");assert(read_steam_pad_info(invalid).empty());
|
||||
std::array<bool,15> b{};std::array<int,6>a{};for(int k=0;k<4;k++){b[k]=true;auto s=steam_pad_state(b,a);assert(s[k]==1);b[k]=false;}
|
||||
b[4]=b[6]=b[7]=b[8]=b[9]=b[10]=true;b[11]=b[14]=true;a={-32768,32767,-15000,8000,32767,16384};auto s=steam_pad_state(b,a);for(int k=4;k<10;k++)assert(s[k]);assert(s[10]==-32768&&s[11]==32767&&s[12]==-15000&&s[13]==8000);assert(s[14]==255&&s[15]==127);assert(s[16]==1&&s[17]==-1);
|
||||
GamepadFeedback f;ff_effect e{};e.id=0;e.type=FF_RUMBLE;e.replay.delay=50;e.replay.length=100;e.u.rumble.strong_magnitude=60000;e.u.rumble.weak_magnitude=12000;assert(f.upload(e)==0);f.play(0,1,1000);assert(f.value(1049,true)[0]==0);assert(f.value(1050,true)[0]==32768);assert(f.value(1149,true)[1]==12000);assert(f.value(1150,true)[0]==0);
|
||||
f.play(0,2,1000);assert(f.value(1150,true)[0]==0);assert(f.value(1200,true)[0]==32768);assert(f.value(1300,true)[0]==0);
|
||||
f.play(0,1,2000);f.value(2050,false);assert(f.value(2051,true)[0]==0);f.play(0,1,3000);f.erase(0);assert(f.value(3050,true)[0]==0);
|
||||
e.id=32;assert(f.upload(e)==-EINVAL);e.id=0;e.type=FF_PERIODIC;assert(f.upload(e)==-EINVAL);e.type=FF_RUMBLE;e.replay.delay=0;e.replay.length=65535;f.upload(e);f.play(0,100,0);assert(f.value(9999,true)[0]>0&&f.value(10000,true)[0]==0);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "gaze_geometry.h"
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
int main(){
|
||||
const double correction[6]{.93,.01,.045,-.02,1.06,-.035};
|
||||
vr::HmdVector3_t target{{-.7f,1.8f,-1.4f}};
|
||||
for(int n=0;n<100;n++){
|
||||
float yaw=(n-50)*.012f,pitch=std::sin(n*.15f)*.2f,roll=std::cos(n*.12f)*.15f;
|
||||
float cy=std::cos(yaw),sy=std::sin(yaw),cp=std::cos(pitch),sp=std::sin(pitch),cr=std::cos(roll),sr=std::sin(roll);
|
||||
vr::HmdMatrix34_t head{{{cy*cr+sy*sp*sr,-cy*sr+sy*sp*cr,sy*cp,std::sin(n*.1f)*.2f},{cp*sr,cp*cr,-sp,1.65f},{-sy*cr+cy*sp*sr,sy*sr+cy*sp*cr,cy*cp,std::cos(n*.1f)*.15f}}};
|
||||
vr::HmdVector3_t origin{{head.m[0][3]+.015f,head.m[1][3]-.02f,head.m[2][3]}};GazeAngles expected{};
|
||||
assert(gaze_angles_to_point(head,origin,target,expected));
|
||||
double ex=expected.yaw-correction[2],ey=expected.pitch-correction[5],det=correction[0]*correction[4]-correction[1]*correction[3];
|
||||
GazeAngles raw{float((correction[4]*ex-correction[1]*ey)/det),float((-correction[3]*ex+correction[0]*ey)/det)};
|
||||
auto ray=gaze_world_ray(head,origin,gaze_correct(raw,correction));GazeAngles round{};assert(gaze_head_angles(head,ray.vDirection,round));assert(std::hypot(round.yaw-expected.yaw,round.pitch-expected.pitch)<1e-5f);
|
||||
float distance=0;for(int i=0;i<3;i++){assert(ray.vSource.v[i]==origin.v[i]);distance+=std::pow(target.v[i]-origin.v[i],2);}distance=std::sqrt(distance);
|
||||
for(int i=0;i<3;i++)assert(std::abs(origin.v[i]+distance*ray.vDirection.v[i]-target.v[i])<1e-5f);
|
||||
}
|
||||
// Runtime UV inversion, including curvature coupling and a displaced seed.
|
||||
auto curved=[](GazeAngles a,float& u,float& v){u=.5f+std::sin(a.yaw);v=.5f+a.pitch+.18f*a.yaw*a.yaw;return u>=0&&u<=1&&v>=0&&v<=1;};
|
||||
for(float u:{.08f,.5f,.92f})for(float v:{.08f,.5f,.92f}){GazeAngles out;assert(gaze_target_inverse({},u,v,curved,out));float x,y;assert(curved(out,x,y));assert(std::hypot(x-u,y-v)<.0003f);}
|
||||
GazeAngles out;assert(!gaze_target_inverse({},.3f,.3f,[](GazeAngles,float&,float&){return false;},out));assert(!gaze_target_inverse({},.3f,.3f,[](GazeAngles,float& u,float& v){u=v=.5f;return true;},out));
|
||||
vr::HmdMatrix34_t invalid{};invalid.m[0][0]=std::numeric_limits<float>::quiet_NaN();assert(!gaze_head_angles(invalid,target,out));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {Launcher,LauncherSettings} from '../ui/src/launcher';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
const reason='Application state is temporarily unavailable. Refresh or reconcile it in APK management.';
|
||||
let unavailable:string|null=null;
|
||||
const calls:{method:string;params:any}[]=[];
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method,params}=JSON.parse(String(init?.body));calls.push({method,params});
|
||||
const result=method==='steam.list'?[{id:0x92345678,name:'Wrapper duplicate'},{id:44,name:'Game Two'}]:method==='desktop.list'?[{id:'shared/game1',name:'Game One',kind:'lepton',steamAppId:0x92345678},{id:'shared/game2',name:'Game Two',kind:'lepton',launchUnavailable:unavailable}]:true;
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
async function until(check:()=>unknown){for(let i=0;i<120;i++){if(check())return;await wait();}throw Error('Timed out');}
|
||||
const icon=(name:string)=>document.querySelector<HTMLButtonElement>(`${name==='Game Two'?'[data-launch-key="lepton:shared/game2"] ':''}.launch-icon[aria-label="${name}"]`);
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>icon('Game Two'));
|
||||
if(icon('Wrapper duplicate')||document.querySelectorAll('.launch-icon[aria-label="Game Two"]').length!==2)throw Error('Explicit Steam binding was not deduplicated, or a same-name unrelated Steam app was removed');
|
||||
icon('Game One')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'));
|
||||
unavailable=reason;window.dispatchEvent(new Event('framely.launcher.open'));
|
||||
await until(()=>icon('Game Two')?.getAttribute('aria-disabled')==='true');
|
||||
if(!icon('Game One')||!icon('Game Two')!.querySelector('.launch-unavailable-mark'))throw Error('Shared app disappeared or has no unavailable indicator');
|
||||
const launches=calls.filter(c=>c.method==='desktop.launch').length;
|
||||
icon('Game Two')!.click();await wait();
|
||||
if(calls.filter(c=>c.method==='desktop.launch').length!==launches||!document.querySelector('[role=alert]')?.textContent?.includes(reason))throw Error('Unavailable app launched without explanation');
|
||||
icon('Game Two')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await until(()=>document.querySelector('[role=menu]'));
|
||||
const menu=document.querySelector('[role=menu]')!;
|
||||
const buttons=[...menu.querySelectorAll<HTMLButtonElement>('button')];
|
||||
if(!buttons.find(b=>b.textContent==='Open')?.disabled||buttons.find(b=>b.textContent==='Manage app')?.disabled)throw Error('Unavailable status disabled app management or allowed launch');
|
||||
unavailable=null;window.dispatchEvent(new Event('framely.launcher.open'));
|
||||
await until(()=>icon('Game Two')?.getAttribute('aria-disabled')==='false');
|
||||
icon('Game Two')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='lepton:shared/game2'));
|
||||
root.render(<div className="app manager"><LauncherSettings config={{primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10}} refresh={async()=>{}}/></div>);await wait(600);
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
configureLanguage('en-US',[]);
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
root.render(<Launcher plugins={{}} safeMode={false} config={{primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10}} favorites={[]} order={[]} refresh={async()=>{}}/>);
|
||||
wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {Launcher} from '../ui/src/launcher';import {configureLanguage} from '../ui/src/i18n';import {fitCalibration,correctedGaze,calibrationBlocks,calibrationValidation} from '../ui/src/gaze-calibration';import '../ui/src/style.css';
|
||||
const wait=(ms:number)=>new Promise(r=>setTimeout(r,ms));const calls:{method:string;params:any}[]=[];
|
||||
window.fetch=async(_url,init)=>{const request=JSON.parse(String(init?.body));calls.push(request);return new Response(JSON.stringify({result:request.method==='steam.list'||request.method==='desktop.list'?[]:request.method==='launcher.gaze.calibration.get'?null:request.method==='launcher.search.index'?{}:true}));};
|
||||
location.hash='#calibrate';configureLanguage('en-US',[]);const root=createRoot(document.getElementById('root')!);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
const degenerate=Array.from({length:8},()=>({raw:{x:.5,y:.5},target:{x:.5,y:.5}}));const stable=fitCalibration(degenerate,1440,800);if(!stable||Math.abs(stable.matrix[0]-1)>.001)throw Error('Head-facing identity prior failed');if(fitCalibration(degenerate.slice(0,5),1440,800))throw Error('Insufficient samples accepted');
|
||||
// Realistic correlated jitter, intermittent spikes, head motion and one
|
||||
// mis-fixated target. Every target has equal influence despite frame counts.
|
||||
const noisy=[];
|
||||
for(let target=0;target<23;target++){const frames=[];for(let n=0;n<60+(target%3)*6;n++){const yaw=(target%5-2)*.2-Math.sin(n/18)*.15,pitch=(Math.floor(target/5)-2)*.13-Math.cos(n/22)*.08;const wrong=target===22?.18:0;frames.push({raw:{x:(yaw-.045)/.93+Math.sin(n*1.7)*.028+wrong,y:(pitch+.035)/1.06+Math.cos(n*1.3)*.02},target:{x:yaw,y:pitch}});}noisy.push(...calibrationBlocks(frames,target));}
|
||||
const robust=fitCalibration(noisy,1440,800);if(!robust)throw Error('Noisy fit failed');
|
||||
for(const yaw of [-.35,0,.35]){const q=correctedGaze({x:(yaw-.045)/.93,y:(.12+.035)/1.06},robust);if(Math.hypot(q.x-yaw,q.y-.12)>.012)throw Error('Noise or bad target corrupted model');}
|
||||
const identity={space:'headAngles' as const,matrix:[1,0,0,0,1,0],width:1440,height:800,error:0,maxError:0};
|
||||
const fixation=Array.from({length:80},(_,n)=>({raw:{x:.2+(n===30?.12:Math.sin(n)*.02),y:.1+Math.cos(n)*.02},target:{x:.2,y:.1}}));
|
||||
if(calibrationValidation(fixation,identity).bias>.005)throw Error('One noisy frame invalidated fixation');
|
||||
const biased=fixation.map(p=>({...p,raw:{x:p.raw.x+.09,y:p.raw.y}}));if(calibrationValidation(biased,identity).bias<.065)throw Error('Systematic bad gaze wrongly passed');
|
||||
const marker=document.createElement('span');marker.style.cssText='position:fixed;left:calc(50% - 2px);top:0;width:4px;height:20px;background:white';document.body.append(marker);await wait(500);const button=(label:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent===label)!;
|
||||
if(document.querySelectorAll('[data-calibration-target]').length!==24)throw Error('Missing calibration targets');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_INTRO_EN');
|
||||
configureLanguage('zh-CN',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(160);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_INTRO_ZH');
|
||||
configureLanguage('en-US',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(100);
|
||||
|
||||
let clock=1000;Object.defineProperty(performance,'now',{value:()=>clock,configurable:true});button('Start calibration').click();await wait(100);if(document.querySelector('.calibration-dialog'))throw Error('Intro dialog hid calibration targets');const notice=document.querySelector('.calibration-live')!.getBoundingClientRect();for(const node of document.querySelectorAll('[data-calibration-target]')){const b=node.getBoundingClientRect();if(b.bottom>notice.top&&b.top<notice.bottom&&b.right>notice.left&&b.left<notice.right)throw Error('Progress notice obscured target');}
|
||||
let completed=0;
|
||||
while(!button('Save calibration')&&completed<32){const node=document.querySelector<HTMLElement>('.calibration-target-active');if(!node)throw Error('No active target');const rect=node.getBoundingClientRect();const tx=(rect.x+rect.width/2)/innerWidth-.5,ty=(.5-(rect.y+rect.height/2)/innerHeight)*.6;const target=calls.filter(r=>r.method==='host.launcher.calibration.target').at(-1)!.params;
|
||||
window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:{valid:false,targetId:target.id}}));
|
||||
for(let n=0;n<80;n++){clock+=30;const headYaw=Math.sin(n/16)*.25,headPitch=Math.cos(n/19)*.12,translation=Math.sin(n/13)*.025;const yaw=tx-headYaw+translation,pitch=ty-headPitch;const raw=[(yaw-.045)/.93+Math.sin(n)*.001,(pitch+.035)/1.06+Math.cos(n)*.001];const c=Math.cos(headYaw),s=Math.sin(headYaw);window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:{valid:true,targetId:target.id,rawAngles:raw,targetAngles:[yaw,pitch],headPose:[c,0,s,translation,0,1,0,1.65,-s,0,c,0],x:rect.x+rect.width/2,y:rect.y+rect.height/2}}));await wait(3);}
|
||||
if(completed===0)console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_LIVE_EN');
|
||||
await wait(80);completed++;
|
||||
}
|
||||
if(completed!==29)throw Error('Wrong collection/validation count '+completed);
|
||||
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_RESULT_EN');
|
||||
button('Save calibration').click();await wait(100);const saved=calls.find(r=>r.method==='launcher.gaze.calibration.save')?.params;if(!saved||saved.error>.005)throw Error('No reliable calibration saved');
|
||||
const q=correctedGaze({x:(.4-.045)/.93,y:(.6+.035)/1.06},saved);if(Math.hypot(q.x-.4,q.y-.6)>.005)throw Error('Correction did not recover known offset/scale');
|
||||
configureLanguage('zh-CN',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_RESULT_ZH');button('完成').click();await wait(150);if(document.querySelector('.launcher-calibration'))throw Error('Calibration did not exit');if(!calls.some(r=>r.method==='host.launcher.calibrate'&&r.params.active===false))throw Error('Native calibration mode not disabled');if(calls.some(r=>r.method==='plugin.launch'))throw Error('Calibration launched real app');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
+20
-4
@@ -3,8 +3,24 @@
|
||||
#include <cmath>
|
||||
int main(){
|
||||
using namespace std::chrono;auto now=steady_clock::now();LauncherFade fade;
|
||||
fade.open(now);assert(fade.value(now)==0);assert(std::abs(fade.value(now+milliseconds(290))-.5f)<.001f);assert(fade.value(now+milliseconds(580))==1);
|
||||
// Closing during entry is continuous; reopening starts a new entry.
|
||||
auto interrupted=now+milliseconds(290);fade.close(interrupted);assert(std::abs(fade.value(interrupted)-.5f)<.001f);assert(std::abs(fade.value(interrupted+milliseconds(220))-.25f)<.001f);assert(fade.value(interrupted+milliseconds(440))==0);
|
||||
fade.open(interrupted);assert(fade.value(interrupted)==0);fade.reduced=true;assert(fade.value(interrupted)==1);fade.close(interrupted);assert(fade.value(interrupted)==0);
|
||||
// Activation/first paint may be late: the fade must not elapse offscreen.
|
||||
fade.open(now);assert(fade.value(now)==0);assert(fade.value(now+seconds(2))==0);
|
||||
const auto visible=now+seconds(2);fade.ready(visible);
|
||||
assert(fade.value(visible)==0);
|
||||
assert(std::abs(fade.value(visible+milliseconds(160))-.875f)<.001f);
|
||||
fade.ready(visible+milliseconds(160)); // Repeated visibility does not restart it.
|
||||
assert(fade.value(visible+milliseconds(320))==1);
|
||||
// Closing during entry remains continuous; interrupted close resets completely.
|
||||
auto interrupted=visible+milliseconds(160);fade.close(interrupted);
|
||||
assert(std::abs(fade.value(interrupted)-.875f)<.001f);
|
||||
assert(std::abs(fade.value(interrupted+milliseconds(120))-.4375f)<.001f);
|
||||
assert(fade.value(interrupted+milliseconds(240))==0);
|
||||
fade.reset();assert(fade.value(interrupted)==0);assert(!fade.waiting);
|
||||
fade.open(interrupted);assert(fade.value(interrupted)==0);
|
||||
fade.ready(interrupted);assert(fade.value(interrupted)==0);
|
||||
fade.reduced=true;assert(fade.value(interrupted)==1);
|
||||
fade.close(interrupted);assert(fade.value(interrupted)==0);
|
||||
// Cancelling before activation never produces a late fade or a visible flash.
|
||||
fade.open(now);fade.close(now+milliseconds(100));assert(fade.value(now+milliseconds(100))==0);
|
||||
fade.reset();fade.ready(now+seconds(3));assert(fade.value(now+seconds(3))==0);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React,{useState} from 'react';import{createRoot}from'react-dom/client';
|
||||
import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import'../ui/src/style.css';
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<120;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,240)+' '+JSON.stringify(calls.slice(-4))+' edit='+!!document.querySelector('.launcher-editing'));};
|
||||
(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession=42;
|
||||
let stalledMethod='',releaseStalled:((r:Response)=>void)|undefined;
|
||||
let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:1};let order:string[]=[];let localApps:any[]=[];let redraw:()=>void;let settings=false;const calls:{method:string;params:any}[]=[];
|
||||
const plugins:Record<string,Plugin>={
|
||||
@@ -8,7 +9,7 @@ const plugins:Record<string,Plugin>={
|
||||
'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'Passthrough color',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1},
|
||||
'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:false,order:2},
|
||||
};
|
||||
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(method===stalledMethod)return new Promise<Response>(resolve=>{releaseStalled=resolve;});let result:any=true;
|
||||
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(["desktop.launch","steam.launch"].includes(method)&¶ms.launcherSession!==42)throw Error("Launch did not carry the current launcher session");if(method===stalledMethod)return new Promise<Response>(resolve=>{releaseStalled=resolve;});let result:any=true;
|
||||
if(method==='steam.list')result=[{id:620980,name:'Beat Saber'},{id:546560,name:'Half-Life: Alyx'},{id:413150,name:'Stardew Valley'},{id:1245620,name:'Elden Ring'},{id:367520,name:'Hollow Knight'},{id:570,name:'Dota 2'},{id:440,name:'Team Fortress 2'}].map(a=>({...a,icon:`/preview-icons/${a.id}.jpg`,iconFit:'cover'}));
|
||||
else if(method==='desktop.list')result=localApps;
|
||||
else if(method==='status')result={database:{plugins,safeMode:false}};
|
||||
@@ -100,10 +101,10 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
|
||||
window.dispatchEvent(new Event('framely.launcher.open'));await wait(100);
|
||||
releaseStalled!(new Response(JSON.stringify({result:{frontend:'actions.js',context:{source:'launcher'}}})));await wait(100);
|
||||
if(document.querySelector('.launcher-action-frame')||document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled)throw Error('Late action response locked or mounted in reopened launcher');
|
||||
releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);
|
||||
releaseStalled=undefined;stalledMethod='desktop.launch';document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>!!releaseStalled);await until(()=>!!document.querySelector('.launch-spinner'));if(document.querySelectorAll('.launch-spinner').length!==1)throw Error('Launch spinner is not scoped to the pending app');await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);if(document.querySelector('.launch-spinner'))throw Error('Timeout left the launch spinner visible');
|
||||
stalledMethod='';releaseStalled!(new Response(JSON.stringify({result:true})));window.setTimeout=realTimeout;await wait(100);
|
||||
console.log('FRAMELY_LAUNCHER_TIMEOUT_RECOVERY_OK');
|
||||
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+1);window.dispatchEvent(new Event('framely.launcher.closing'));await wait(80);const closingAnimation=document.querySelector('.launcher-shell')!.getAnimations()[0];if(!closingAnimation||Number((closingAnimation.effect as KeyframeEffect).getKeyframes().at(-1)?.opacity)!==0)throw Error('Closing animation missing');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);
|
||||
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
|
||||
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+1);window.dispatchEvent(new Event('framely.launcher.closing'));await wait(80);const surface=document.querySelector<HTMLElement>('.launcher-shell')!;if(surface.style.pointerEvents!=='none'||surface.getAnimations().length)throw Error('Native fade must suppress input without a second page animation');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);if(surface.style.pointerEvents==='none')throw Error('Reopened launcher kept closing input state');
|
||||
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(config.prioritizeUi!==false)throw Error("Resource priority setting not saved");switches[1].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "launcher_gaze.h"
|
||||
#include <cassert>
|
||||
#include <limits>
|
||||
int main(){
|
||||
vr::VREyeTrackingData_t e{};vr::VROverlayIntersectionParams_t ray{};assert(!launcher_gaze_ray(e,ray));
|
||||
e.bActive=e.bValid=e.bTracked=true;e.vGazeOrigin={{1,2,3}};e.vGazeTarget={{1,2,1}};assert(launcher_gaze_ray(e,ray));assert(ray.vDirection.v[2]==-1&&ray.vSource.v[0]==1);
|
||||
e.vGazeTarget=e.vGazeOrigin;assert(!launcher_gaze_ray(e,ray));e.vGazeTarget.v[0]=std::numeric_limits<float>::quiet_NaN();assert(!launcher_gaze_ray(e,ray));
|
||||
LauncherGazePress p;bool connected[2]{true,true},buttons[2]{true,false};
|
||||
assert(p.update(true,connected,buttons)==LauncherGazePress::None); // entry held
|
||||
buttons[0]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
|
||||
buttons[0]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
|
||||
buttons[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
|
||||
buttons[0]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::Up);
|
||||
assert(p.update(true,connected,buttons)==LauncherGazePress::None); // overlapping button doesn't re-click
|
||||
buttons[1]=false;p.update(true,connected,buttons);buttons[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
|
||||
connected[1]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::Cancel);
|
||||
connected[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
|
||||
buttons[1]=false;p.update(true,connected,buttons);buttons[0]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
|
||||
assert(p.update(false,connected,buttons)==LauncherGazePress::Cancel);
|
||||
assert(p.update(true,connected,buttons)==LauncherGazePress::None);
|
||||
// A laser aimed at the Dock entry owns this press even if gaze previously
|
||||
// focused a launcher icon. Moving away while held must not become gaze-down.
|
||||
for(int hand=0;hand<2;hand++){
|
||||
LauncherGazePress dock;bool online[2]{true,true},pressed[2]{false,false};
|
||||
dock.update(true,online,pressed);pressed[hand]=true;
|
||||
assert(dock.update(false,online,pressed)==LauncherGazePress::None);
|
||||
assert(dock.update(true,online,pressed)==LauncherGazePress::None);
|
||||
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::None);
|
||||
pressed[hand]=true;assert(dock.update(true,online,pressed)==LauncherGazePress::Down);
|
||||
// Dock mouse-down cancels an already-held gaze target instead of releasing
|
||||
// it (release would click). Same-frame down/up remains disarmed while held.
|
||||
assert(dock.cancel()==LauncherGazePress::Cancel);
|
||||
assert(!dock.ready&&!dock.held&&dock.owner==-1);
|
||||
assert(dock.update(true,online,pressed)==LauncherGazePress::None);
|
||||
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::None);
|
||||
pressed[hand]=true;assert(dock.update(true,online,pressed)==LauncherGazePress::Down);
|
||||
pressed[hand]=false;assert(dock.update(true,online,pressed)==LauncherGazePress::Up);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';import {createRoot} from 'react-dom/client';
|
||||
import {Launcher} from '../ui/src/launcher';import {configureLanguage} from '../ui/src/i18n';import '../ui/src/style.css';
|
||||
const wait=(ms:number)=>new Promise(r=>setTimeout(r,ms));const calls:string[]=[];
|
||||
window.fetch=async(_url,init)=>{const {method}=JSON.parse(String(init?.body));calls.push(method);return new Response(JSON.stringify({result:method==='steam.list'||method==='desktop.list'?[]:method==='launcher.search.index'?{}:true}));};
|
||||
const plugin:any={manifest:{id:'demo.test',name:'Gaze app',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:0};
|
||||
createRoot(document.getElementById('root')!).render(<Launcher plugins={{'demo.test':plugin}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);
|
||||
function send(d:any){window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:d}));}
|
||||
async function gaze(node:Element,duration=180){const b=node.getBoundingClientRect(),x=b.x+b.width/2,y=b.y+b.height/2;for(let t=0;t<duration;t+=30){send({valid:true,x,y});await wait(30);}}
|
||||
async function hold(node:Element,duration:number){send({valid:true,phase:'down'});for(let t=0;t<duration;t+=30){send({valid:true,x:0,y:0});await wait(30);}}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);const marker=document.createElement('div');marker.style.cssText='position:fixed;left:calc(50% - 2px);top:0;width:4px;height:20px;background:white';document.body.append(marker);await wait(900);
|
||||
const icon=()=>document.querySelector<HTMLButtonElement>('.launch-icon[aria-label="Gaze app"]')!;
|
||||
await gaze(icon());if(!icon().classList.contains('gaze-focused'))throw Error('No gaze focus');
|
||||
const bounds=icon().getBoundingClientRect();for(let n=0;n<16;n++){send({valid:true,x:bounds.right+20+Math.sin(n)*8,y:bounds.y+bounds.height/2+Math.cos(n)*12});await wait(20);}if(!icon().classList.contains('gaze-focused'))throw Error('Edge jitter lost focus');
|
||||
send({valid:false});await wait(90);if(!icon().classList.contains('gaze-focused'))throw Error('Brief dropout cleared hover');await gaze(icon());
|
||||
send({valid:true,phase:'down'});await gaze(document.querySelector('.launcher-filters button')!,60);send({valid:true,phase:'up'});await wait(80);
|
||||
if(calls.filter(m=>m==='plugin.launch').length!==1)throw Error('Press did not lock target');
|
||||
await gaze(icon());await hold(icon(),450);send({valid:true,phase:'up'});await wait(80);
|
||||
if(!document.querySelector('.launch-menu')||calls.filter(m=>m==='plugin.launch').length!==1)throw Error('400 ms hold did not open only menu');
|
||||
window.dispatchEvent(new Event('framely.launcher.cancelInput'));await wait(80);
|
||||
await gaze(icon());await hold(icon(),850);send({valid:true,phase:'up'});await wait(80);
|
||||
if(!document.querySelector('.launcher-editing'))throw Error('800 ms hold did not edit');
|
||||
window.dispatchEvent(new Event('framely.back'));await wait(80);
|
||||
await gaze(icon());send({valid:true,phase:'down'});send({valid:false,phase:'cancel'});await wait(450);send({valid:true,phase:'up'});await wait(80);
|
||||
if(document.querySelector('.launch-menu')||calls.filter(m=>m==='plugin.launch').length!==1)throw Error('Tracking loss launched or held');
|
||||
await gaze(icon());await wait(360);if(icon().classList.contains('gaze-focused'))throw Error('Stale gaze remains active');
|
||||
const search=document.querySelector<HTMLInputElement>('.launcher-search input')!;await gaze(search);send({valid:true,phase:'down'});send({valid:true,phase:'up'});if(document.activeElement!==search)throw Error('Search not focused');
|
||||
window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:true}));await gaze(icon());if(icon().classList.contains('gaze-focused'))throw Error('Gaze active behind keyboard');
|
||||
window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:false}));
|
||||
const shortcuts=Array.from(document.querySelectorAll<HTMLButtonElement>('.launcher-shortcuts button'));
|
||||
const calibration=shortcuts.find(b=>b.getAttribute('aria-label')==='Eye tracking calibration');
|
||||
if(!calibration||shortcuts.indexOf(calibration)!==shortcuts.findIndex(b=>b.getAttribute('aria-label')==='Settings')-1)throw Error('Calibration shortcut is not above settings');
|
||||
calibration.click();await wait(80);if(!calls.includes('host.launcher.calibrate'))throw Error('Calibration shortcut did not call native calibration');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -8,10 +8,11 @@ window.fetch=async(_url,init)=>{const {method,params}=JSON.parse(String(init?.bo
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const nav=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.workspace>nav button')].find(b=>b.textContent===label)!;
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==6)throw Error('Missing launcher controls');
|
||||
nav('Settings').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));
|
||||
const switcher=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled);
|
||||
const categories=document.querySelectorAll<HTMLInputElement>('.launcher-settings [role=switch]');categories[3].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[3].disabled);
|
||||
await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==7)throw Error('Missing launcher controls');
|
||||
nav('General').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));
|
||||
const priority=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch][aria-label="Prioritize launcher resources"]')!;priority.click();await until(()=>launcher.prioritizeUi===false);await until(()=>!priority.disabled);
|
||||
const switcher=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch][aria-label="Open launcher with the primary trigger"]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled);
|
||||
const categories=document.querySelectorAll<HTMLInputElement>('.launcher-settings [role=switch]');categories[4].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[4].disabled);
|
||||
await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_EN');const content=document.querySelector<HTMLElement>('.content')!;content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_EN');content.scrollTop=0;language='zh-CN';configureLanguage(language,[]);location.hash='#launcher-settings';window.dispatchEvent(new Event('hashchange'));await until(()=>document.documentElement.lang==='zh-CN'&&!!nav('启动台'));await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_ZH');content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_ZH');
|
||||
const first=document.querySelector('.launcher-settings .switch-row')!.getBoundingClientRect();const second=document.querySelectorAll('.launcher-settings')[1].querySelector('.switch-row')!.getBoundingClientRect();if(Math.abs(first.width-second.width)>1)throw Error('Settings widths differ');console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "launcher_priority.h"
|
||||
#include <cassert>
|
||||
#include <thread>
|
||||
|
||||
int main(){
|
||||
assert(LauncherPriority::target(0)==-5);
|
||||
assert(LauncherPriority::target(19)==-5);
|
||||
assert(LauncherPriority::target(-10)==-10);
|
||||
const int original=getpriority(PRIO_PROCESS,0);
|
||||
{
|
||||
LauncherPriority priority;priority.update(true);
|
||||
// Without RLIMIT_NICE the boost fails safely; with it, verify the bounded boost.
|
||||
int boosted=getpriority(PRIO_PROCESS,0);
|
||||
assert(boosted==original||boosted==LauncherPriority::target(original));
|
||||
std::thread newborn([]{std::this_thread::sleep_for(std::chrono::milliseconds(150));});
|
||||
priority.update(false);
|
||||
assert(getpriority(PRIO_PROCESS,0)==original);
|
||||
newborn.join();
|
||||
}
|
||||
assert(getpriority(PRIO_PROCESS,0)==original);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
|
||||
const settings=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='设置') as HTMLButtonElement;
|
||||
settings.click();await until(()=>!!document.querySelector('.quick-settings'));
|
||||
if(document.querySelector('.quick-settings input[type=file]')||document.querySelector('.quick-settings [download]'))throw Error('Quick settings exposes language installation or template download');
|
||||
const safe=document.querySelector('.setting-toggle') as HTMLElement,languageCard=document.querySelector('.language-settings') as HTMLElement;
|
||||
const safe=document.querySelector('[role=switch][aria-label="安全模式"]') as HTMLElement,languageCard=document.querySelector('.language-settings') as HTMLElement;
|
||||
if(languageCard.getBoundingClientRect().top-safe.getBoundingClientRect().bottom<24)throw Error('Safe mode is too close to language divider');
|
||||
button('界面语言').click();await wait();
|
||||
const english=[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English') as HTMLButtonElement;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "plugin_navigation.h"
|
||||
#include <cassert>
|
||||
int main() {
|
||||
for (const auto& page : {"settings","updates","about","terminal","files","apk-containers","apk-settings"}) assert(manager_navigation_page(page));
|
||||
assert(!manager_navigation_page("unknown"));
|
||||
const std::string origin="http://localhost:19626";
|
||||
assert(allow_plugin_navigation(origin+"/window",origin,true,true));
|
||||
assert(allow_plugin_navigation("about:blank",origin,false,false));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const calls=[];
|
||||
const app=(id,remote,extra={})=>({appid:id,display_name:`Game ${id}`,app_type:1,visible_in_game_list:true,remote_per_client_data:remote,BIsPerClientDataLocal:r=>r.clientid==='local',...extra});
|
||||
const context={window:{appStore:{allApps:[app(1,[{clientid:'12',installed:true}]),app(2,[{clientid:'13',installed:true}]),app(3,[{clientid:'12',installed:false}]),app(4,[{clientid:'local',installed:true}]),app(5,[{clientid:'12',installed:true}],{app_type:8192}),app(6,[{clientid:'12',installed:true}],{visible_in_game_list:false})]},RemotePlayStore_SteamUI:{devices:[{clientId:'12',status:'Connected'},{clientId:'13',status:'Disconnected'},{clientId:'local',status:'Connected'}]}},SteamClient:{Apps:{StreamGame:(...args)=>calls.push(args)}}};
|
||||
const query=vm.runInNewContext(`(${fs.readFileSync('src/steam/remote.js','utf8')})`,context);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(await query({mode:'list'}))),[{id:1,name:'Game 1',client:'12'}]);
|
||||
assert.equal(await query({mode:'launch',id:1,client:'13'}),false);
|
||||
assert.equal(await query({mode:'launch',id:999,client:'12'}),false);
|
||||
assert.equal(calls.length,0);
|
||||
assert.equal(await query({mode:'launch',id:1,client:'12'}),true);
|
||||
assert.deepEqual(calls,[[1,'12',-1]]);
|
||||
context.window.RemotePlayStore_SteamUI.devices=[];
|
||||
assert.equal(await query({mode:'launch',id:1,client:'12'}),false);
|
||||
assert.equal((await query({mode:'list'})).length,0);
|
||||
context.window.appStore=null;
|
||||
assert.equal((await query({mode:'list'})).length,0);
|
||||
console.log('Steam remote inventory and validated launch: passed');
|
||||
@@ -5,6 +5,9 @@ import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
spec = importlib.util.spec_from_file_location('diagnostics', pathlib.Path(__file__).parents[1] / 'tools/export-diagnostics.py')
|
||||
@@ -56,5 +59,66 @@ class DiagnosticsTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(data), diag.MAX_FILE)
|
||||
self.assertTrue(data.endswith(b'LAST ERROR'))
|
||||
|
||||
def test_apk_logs_summary_are_bounded_and_exclude_application_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
home = root / 'user'
|
||||
apk = home / '.local/share/framely/apk-manager'
|
||||
(apk / 'logs').mkdir(parents=True)
|
||||
(apk / 'apks').mkdir()
|
||||
(apk / 'apks/private.apk').write_text('PRIVATE_APK_CONTENT')
|
||||
(apk / 'state.json').write_text(json.dumps({'records': {'ctx/pkg': {
|
||||
'context':'ctx', 'steamToken':'PRIVATE_BINDING_TOKEN',
|
||||
'metadata': {'package':'test.pkg', 'name':'Test app', 'icon':'PRIVATE_ICON'},
|
||||
'removed':False, 'pending':'uninstall'}}}))
|
||||
for i in range(14):
|
||||
path = apk / 'logs' / f'{i:02}.log'
|
||||
path.write_text(f'operation {i} token=PRIVATE_LOG_TOKEN')
|
||||
import os
|
||||
os.utime(path, (i + 1, i + 1))
|
||||
saved = home / '.local/share/Steam/logs/lepton-logcats/ctx'
|
||||
saved.mkdir(parents=True)
|
||||
(saved / 'logcat-crash.log').write_text('Android boot failure password=PRIVATE_PASSWORD')
|
||||
(saved / 'video.mp4').write_text('PRIVATE_MEDIA')
|
||||
(saved / 'logcat-symlink.log').symlink_to(saved / 'video.mp4')
|
||||
result = diag.collect(root, commands=False, runtime=root/'runtime', steam_home=home)
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
self.assertIn('apk/summary.json', archive.namelist())
|
||||
self.assertIn('apk/android-saved/ctx/logcat-crash.log', archive.namelist())
|
||||
self.assertNotIn('apk/operations/00.log', archive.namelist())
|
||||
self.assertIn('apk/operations/13.log', archive.namelist())
|
||||
text = '\n'.join(archive.read(n).decode() for n in archive.namelist())
|
||||
self.assertIn('Android boot failure', text)
|
||||
self.assertIn('Test app', text)
|
||||
self.assertNotIn('PRIVATE_', text)
|
||||
|
||||
def test_active_container_diagnostics_use_session_user_and_keep_timeout_output(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
(root/'steam-user').write_text('teststeam')
|
||||
home = root/'user'
|
||||
user = SimpleNamespace(pw_name='teststeam', pw_dir=str(home), pw_uid=1000)
|
||||
calls = []
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
if 'ps' in args:
|
||||
kwargs['stdout'].write(b'lepton-active\tUp 1 minute\nlepton-stopped\tExited (0)\n')
|
||||
elif 'exec' in args:
|
||||
kwargs['stdout'].write(b'=== boot ===\n0\n=== user ===\nRUNNING_LOCKED\n')
|
||||
raise subprocess.TimeoutExpired(args, 8)
|
||||
return SimpleNamespace(returncode=0)
|
||||
with mock.patch.object(diag.pwd,'getpwnam',return_value=user), mock.patch.object(diag.os,'geteuid',return_value=0), mock.patch.object(diag.subprocess,'run',side_effect=run):
|
||||
result = diag.collect(root, runtime=root/'runtime')
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
text = archive.read('apk/android-live/lepton-active.log').decode()
|
||||
self.assertIn('RUNNING_LOCKED', text)
|
||||
executions = [a for a in calls if 'exec' in a]
|
||||
self.assertEqual(len(executions), 1)
|
||||
self.assertEqual(executions[0][:4], ['runuser','-u','teststeam','--'])
|
||||
self.assertIn('HOME='+str(home), executions[0])
|
||||
self.assertIn('XDG_RUNTIME_DIR=/run/user/1000', executions[0])
|
||||
self.assertNotIn('start', executions[0])
|
||||
self.assertTrue(any('partial output' in w for w in json.loads(archive.read('report.json'))['warnings']))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Steam mode must not register an OpenVR action application."""
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(platform.machine() == 'aarch64' and os.geteuid() != 0 and shutil.which('g++'), 'ARM64 native bridge requires a non-root user and g++')
|
||||
class GamepadStartup(unittest.TestCase):
|
||||
def test_steam_skips_openvr_but_frame_direct_initializes_it(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / 'target', prefix='gamepad-startup-') as directory:
|
||||
work = pathlib.Path(directory)
|
||||
vr = work / 'vr.cpp'
|
||||
symbols = ['VR_InitInternal', 'VR_InitInternal2', 'VR_ShutdownInternal', 'VR_GetGenericInterface', 'VR_GetInitToken', 'VR_IsInterfaceVersionValid']
|
||||
vr.write_text('#include <cstdio>\n#include <cstdlib>\n' + '\n'.join('extern "C" void ' + name + '(){fputs("OPENVR_USED\\n",stderr);exit(91);}' for name in symbols))
|
||||
subprocess.run(['g++', '-shared', '-fPIC', str(vr), '-o', str(work / 'libopenvr_api.so')], check=True)
|
||||
sdl = work / 'sdl.cpp'
|
||||
sdl.write_text('''#include <cstdio>
|
||||
#include <cstdint>
|
||||
extern "C" {
|
||||
bool SDL_Init(uint32_t){fputs("SDL_USED\\n",stderr);return true;}
|
||||
void SDL_Quit(){} void SDL_UpdateGamepads(){}
|
||||
void SDL_PumpEvents(){fputs("SDL_PUMPED\\n",stderr);}
|
||||
void SDL_FlushEvents(uint32_t,uint32_t){fputs("SDL_FLUSHED\\n",stderr);}
|
||||
uint32_t* SDL_GetGamepads(int* n){*n=0;return nullptr;}
|
||||
void SDL_free(void*){} void* SDL_OpenGamepad(uint32_t){return nullptr;}
|
||||
void SDL_CloseGamepad(void*){} const char* SDL_GetGamepadPathForID(uint32_t){return nullptr;}
|
||||
bool SDL_GetGamepadButton(void*,int){return false;} int16_t SDL_GetGamepadAxis(void*,int){return 0;}
|
||||
bool SDL_RumbleGamepad(void*,uint16_t,uint16_t,uint32_t){return true;}
|
||||
}
|
||||
''')
|
||||
subprocess.run(['g++', '-shared', '-fPIC', str(sdl), '-o', str(work / 'libsdl-test.so')], check=True)
|
||||
loop = work / 'loop.cpp'
|
||||
loop.write_text('#include "gamepad_steam.h"\nint main(int argc,char** argv){SteamGamepad pad;if(!pad.start(argv[1],argv[2]))return 1;pad.read(0,false);pad.read(501,false);}')
|
||||
subprocess.run(['g++', '-std=c++17', '-I'+str(ROOT/'native'), str(loop), '-ldl', '-o', str(work/'loop')], check=True)
|
||||
result = subprocess.run([str(work/'loop'), str(work/'libsdl-test.so'), str(work/'info')], capture_output=True, text=True, check=True)
|
||||
self.assertEqual(result.stderr.count('SDL_PUMPED'), 2)
|
||||
self.assertEqual(result.stderr.count('SDL_FLUSHED'), 2)
|
||||
helper = work / 'bridge'
|
||||
subprocess.run(['g++', '-std=c++17', '-O2', '-I'+str(ROOT/'native/vendor/openvr'), str(ROOT/'native/gamepad.cpp'), '-L'+str(ROOT/'native/vendor/openvr/lib/linuxarm64'), '-lopenvr_api', '-ldl', '-pthread', '-o', str(helper)], check=True)
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=str(work), FRAMELY_STEAM_SDL_LIBRARY=str(work/'libsdl-test.so'), FRAMELY_STEAM_GAMEPAD_INFO=str(work/'info'))
|
||||
args = [str(helper), '/nonexistent/actions.json', '/nonexistent/app.vrmanifest', 'framely.test']
|
||||
steam = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='steam'), input='', capture_output=True, text=True, timeout=10)
|
||||
self.assertIn('SDL_USED', steam.stderr)
|
||||
self.assertNotIn('OPENVR_USED', steam.stderr)
|
||||
self.assertNotIn('Invalid gamepad action', steam.stderr)
|
||||
frame = subprocess.run(args, env=dict(env, FRAMELY_GAMEPAD_SOURCE='frame'), input='', capture_output=True, text=True, timeout=10)
|
||||
self.assertIn('OPENVR_USED', frame.stderr)
|
||||
self.assertNotIn('SDL_USED', frame.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -123,6 +123,8 @@ class InstallRetry(unittest.TestCase):
|
||||
self.assertTrue(state.is_symlink())
|
||||
self.assertTrue((units / 'framely.service').is_file())
|
||||
self.assertTrue((units / 'framely-session.service').is_file())
|
||||
self.assertIn('LimitNICE=25', (units / 'framely-session.service').read_text())
|
||||
self.assertNotIn('LimitNICE=', (units / 'framely.service').read_text())
|
||||
self.assertFalse((base / 'useradd-args').exists())
|
||||
self.assertEqual((state / 'previous-release').read_text(), 'releases/older\n')
|
||||
self.assertEqual((state / 'state.json').read_text(), '{"settings":"preserved"}')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Execute the Steam cleanup expression against a simulated Steam client."""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ArtworkCleanup(unittest.TestCase):
|
||||
def run_cleanup(self, target):
|
||||
source = (ROOT / 'src/apk/steam_ui.rs').read_text().split('pub(super) fn clear_artwork', 1)[1]
|
||||
template = re.search(r'r#"(.*?)"#', source, re.S).group(1)
|
||||
expression = template.replace('{parameters}', '__PARAMETERS__').replace('{{', '{').replace('}}', '}')
|
||||
expression = expression.replace('__PARAMETERS__', json.dumps({'id': 2452903544, 'exe': '/owned/base.apk'}))
|
||||
setup = f'''
|
||||
const calls=[];
|
||||
const appStore={{m_mapApps:new Map([[2452903544,{{appid:2452903544}}]])}};
|
||||
const SteamClient={{Apps:{{
|
||||
RegisterForAppDetails(id,cb){{cb({{strShortcutExe:{json.dumps(target)}}});return {{unregister(){{}}}};}},
|
||||
async ClearCustomArtworkForApp(id,kind){{calls.push(['clear',id,kind]);}},
|
||||
SetShortcutIcon(id,path){{calls.push(['icon',id,path]);}}
|
||||
}}}};
|
||||
'''
|
||||
script = setup + f'''Promise.resolve({expression}).then(()=>console.log(JSON.stringify({{calls,ok:true}})))
|
||||
.catch(()=>console.log(JSON.stringify({{calls,ok:false}})));'''
|
||||
result = subprocess.run(['node', '-e', script], capture_output=True, text=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_clears_every_injected_image_and_shortcut_icon(self):
|
||||
result = self.run_cleanup('"/owned/./base.apk"')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 3, 4]]
|
||||
+ [['icon', 2452903544, '']])
|
||||
|
||||
def test_refuses_another_shortcut_with_the_same_id(self):
|
||||
result = self.run_cleanup('/other/game.exe')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['calls'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Exercise cached audio with a fake SDL, without speakers/Steam/CEF."""
|
||||
import pathlib, subprocess, tempfile
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = pathlib.Path(tmp)
|
||||
(tmp / 'steamrtarm64').mkdir()
|
||||
fake = tmp / 'fake.cpp'
|
||||
fake.write_text(r'''
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
static int loads=0, writes=0, streams=0, cleared=0;
|
||||
extern "C" {
|
||||
bool SDL_InitSubSystem(uint32_t flags){assert(flags==0x10);return true;}
|
||||
void SDL_QuitSubSystem(uint32_t flags){assert(flags==0x10);assert(streams==0);}
|
||||
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
|
||||
void* SDL_OpenAudioDeviceStream(uint32_t id,const void*,void* callback,void* userdata){assert(id==0xffffffffu&&!callback&&!userdata);++streams;return malloc(1);}
|
||||
bool SDL_ResumeAudioStreamDevice(void*){return true;}
|
||||
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==2&&len==4);++writes;return true;}
|
||||
bool SDL_ClearAudioStream(void*){++cleared;return true;}
|
||||
void SDL_DestroyAudioStream(void* stream){--streams;free(stream);}
|
||||
void SDL_free(void* data){free(data);}
|
||||
int counts(int which){return which==0?loads:which==1?writes:cleared;}
|
||||
}
|
||||
''')
|
||||
library=tmp/'steamrtarm64/libSDL3.so.0'
|
||||
subprocess.run(['g++','-shared','-fPIC',str(fake),'-o',str(library)],check=True)
|
||||
test=tmp/'test.cpp'
|
||||
test.write_text(r'''
|
||||
#include "ui_feedback.h"
|
||||
#include <cassert>
|
||||
int main(int argc,char** argv){
|
||||
SteamUiSounds sounds;assert(!sounds.prepare("/missing"));sounds.play();sounds.silence();
|
||||
assert(sounds.prepare(argv[1]));
|
||||
auto lib=dlopen((std::string(argv[1])+"/steamrtarm64/libSDL3.so.0").c_str(),RTLD_NOW);
|
||||
auto counts=reinterpret_cast<int(*)(int)>(dlsym(lib,"counts"));
|
||||
sounds.play();sounds.play(true);sounds.silence();
|
||||
assert(counts(0)==2&&counts(1)==2&&counts(2)==6);
|
||||
sounds.shutdown();sounds.play();assert(counts(1)==2);dlclose(lib);
|
||||
}
|
||||
''')
|
||||
subprocess.run(['g++','-std=c++17','-I'+str(root/'native'),str(test),'-ldl','-o',str(tmp/'test')],check=True)
|
||||
subprocess.run([str(tmp/'test'),str(tmp)],check=True)
|
||||
print('Steam UI cached sound playback: ok')
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user