Compare commits
568
Commits
a681ccb526
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0577ee87a3 | ||
|
|
75aa5ee82c | ||
|
|
1f6e15127a | ||
|
|
f8acb50156 | ||
|
|
7521c47944 | ||
|
|
aa61565fe7 | ||
|
|
4e1323212b | ||
|
|
96f15c79fb | ||
|
|
ab5b593708 | ||
|
|
ff78fe2cae | ||
|
|
5959a27add | ||
|
|
aa8c8f506a | ||
|
|
6dd88a27a3 | ||
|
|
40a296471d | ||
|
|
bb8be87c8f | ||
|
|
fbb4a79955 | ||
|
|
994ffda626 | ||
|
|
a91930e021 | ||
|
|
4b30bf07f0 | ||
|
|
1748e046f8 | ||
|
|
96753c5b38 | ||
|
|
d8ca411117 | ||
|
|
391ebf88b7 | ||
|
|
413553d3ea | ||
|
|
e3ae71ac37 | ||
|
|
a853986283 | ||
|
|
b7a1abb18a | ||
|
|
46559910c9 | ||
|
|
8a050c4402 | ||
|
|
92ab3d4ddf | ||
|
|
a48d055118 | ||
|
|
67a03f9402 | ||
|
|
d4aa3b23f4 | ||
|
|
4c29cbac3e | ||
|
|
5dda9ac31d | ||
|
|
ab573838f3 | ||
|
|
0cc1a35420 | ||
|
|
7995f62908 | ||
|
|
b31ca636d1 | ||
|
|
c97630d59e | ||
|
|
8e37bb9cf9 | ||
|
|
3b5f95375d | ||
|
|
ba96a4fc94 | ||
|
|
133f14ba0b | ||
|
|
06fcbb8a51 | ||
|
|
9163054d46 | ||
|
|
56402d97a1 | ||
|
|
50d13d26ab | ||
|
|
de41e4c3b1 | ||
|
|
540b82e197 | ||
|
|
528f5706a0 | ||
|
|
19cc5c7950 | ||
|
|
1f2d89c23e | ||
|
|
aae9a233ff | ||
|
|
7f697f5762 | ||
|
|
6e80fd3563 | ||
|
|
1623f68483 | ||
|
|
1764301cdf | ||
|
|
9bb9855392 | ||
|
|
54dde7f494 | ||
|
|
94f56f9d9c | ||
|
|
e8544b19a7 | ||
|
|
98262d3835 | ||
|
|
fb0b1a5311 | ||
|
|
0e6d21ba89 | ||
|
|
59011b2d56 | ||
|
|
b1524635ba | ||
|
|
db5b08b9ab | ||
|
|
1441a240fe | ||
|
|
3116f43c9f | ||
|
|
476bf92f97 | ||
|
|
26e7b7333e | ||
|
|
961ff453a0 | ||
|
|
5a52f38f35 | ||
|
|
5505ea0f91 | ||
|
|
93bd704786 | ||
|
|
a1876a08c0 | ||
|
|
393d6f188f | ||
|
|
a219a21db8 | ||
|
|
918d826645 | ||
|
|
b7d9b99e12 | ||
|
|
44a0c87a11 | ||
|
|
edd12b38ab | ||
|
|
8c5bebf3e3 | ||
|
|
5fd5cd47dc | ||
|
|
efc663a3cb | ||
|
|
2b6dcbed40 | ||
|
|
3cd371b537 | ||
|
|
414f6ff886 | ||
|
|
7b79d5c397 | ||
|
|
29da7e2e47 | ||
|
|
1d240ff1f2 | ||
|
|
c4c3c15285 | ||
|
|
65e7ccc3bf | ||
|
|
7e8b8740c4 | ||
|
|
2813e2984c | ||
|
|
a14b00aced | ||
|
|
dc3fa2be87 | ||
|
|
c198a3c995 | ||
|
|
124e474656 | ||
|
|
ca92014b81 | ||
|
|
9a7b138d08 | ||
|
|
ce463cc9b5 | ||
|
|
b457f0acc8 | ||
|
|
e386cfbeee | ||
|
|
ff2f229b80 | ||
|
|
aa33210bb6 | ||
|
|
011d525e8d | ||
|
|
94f792e978 | ||
|
|
a40b3a5940 | ||
|
|
f82bd395f8 | ||
|
|
26ba1579ab | ||
|
|
3fd33a672f | ||
|
|
329c529bea | ||
|
|
e5a4b9bbd1 | ||
|
|
df331f6a60 | ||
|
|
14f9318e09 | ||
|
|
7fdebba961 | ||
|
|
30b61e2684 | ||
|
|
c72bf10690 | ||
|
|
fd4a5f87d9 | ||
|
|
6f78e0cf7e | ||
|
|
93d02ab12b | ||
|
|
9cb605f316 | ||
|
|
82f3bbbff6 | ||
|
|
28762ec268 | ||
|
|
037c68d0e6 | ||
|
|
0451b3f972 | ||
|
|
da5b837c7c | ||
|
|
460da9330d | ||
|
|
b9009c0e61 | ||
|
|
875b792206 | ||
|
|
449ded2cb7 | ||
|
|
2a004a5754 | ||
|
|
9700de1bd4 | ||
|
|
ef77c67d3c | ||
|
|
36ff193609 | ||
|
|
ab79a28b28 | ||
|
|
d7adbd1172 | ||
|
|
68a1f90828 | ||
|
|
28bf104041 | ||
|
|
24826c1a7c | ||
|
|
a14b9e17c1 | ||
|
|
add7f2e672 | ||
|
|
72767067f3 | ||
|
|
5965575210 | ||
|
|
9b23221a62 | ||
|
|
49a2fafe51 | ||
|
|
5e1f0868f1 | ||
|
|
afe169751f | ||
|
|
46389d61fd | ||
|
|
b4e6fbf8c1 | ||
|
|
5ff6cdb38b | ||
|
|
e858664fca | ||
|
|
15a19bb375 | ||
|
|
1d2e84ed61 | ||
|
|
5d5fe8aa73 | ||
|
|
c319f9dbfd | ||
|
|
47ed270293 | ||
|
|
ecd24f0608 | ||
|
|
b394316df4 | ||
|
|
81278f55af | ||
|
|
0241f793b2 | ||
|
|
79b796fd58 | ||
|
|
6804b0ad1d | ||
|
|
03741ecd2d | ||
|
|
b2d9f3927f | ||
|
|
d568cc6f37 | ||
|
|
4defc41e8d | ||
|
|
a4f096eea3 | ||
|
|
04ac90911e | ||
|
|
1c8f6aa1b3 | ||
|
|
a80362915a | ||
|
|
ee67965b9b | ||
|
|
211e07b470 | ||
|
|
c76a1437e6 | ||
|
|
e2021645d7 | ||
|
|
d68523b02a | ||
|
|
3b3430a93e | ||
|
|
cb3ae8556b | ||
|
|
88ac9d5aca | ||
|
|
4dbeb07220 | ||
|
|
c82e39d0eb | ||
|
|
161aec52f4 | ||
|
|
41019d9cae | ||
|
|
fe472e36a5 | ||
|
|
394faad611 | ||
|
|
23c2abebc2 | ||
|
|
0e7ae44923 | ||
|
|
7eed6c6261 | ||
|
|
02ed09ae49 | ||
|
|
173a18b842 | ||
|
|
bbb316ca08 | ||
|
|
c434dbe335 | ||
|
|
7b971dc75d | ||
|
|
48d1ee6616 | ||
|
|
1e0fc44fed | ||
|
|
4582a025db | ||
|
|
e090c67297 | ||
|
|
ac2e163eb8 | ||
|
|
aa42496561 | ||
|
|
4bb1591aca | ||
|
|
be280c16a3 | ||
|
|
d728ed836f | ||
|
|
37de00b07e | ||
|
|
06fe12d811 | ||
|
|
c38fbe4068 | ||
|
|
f001b93942 | ||
|
|
afdbc9fd51 | ||
|
|
7946e5b804 | ||
|
|
5c96227881 | ||
|
|
f9607c1246 | ||
|
|
e3ee20bdcd | ||
|
|
87ab8783a3 | ||
|
|
5597a4cc18 | ||
|
|
1c53c29f76 | ||
|
|
166342b021 | ||
|
|
fdb9ebb014 | ||
|
|
ae1410f342 | ||
|
|
8bfabafe8a | ||
|
|
2e16df0191 | ||
|
|
f4cab82c65 | ||
|
|
374e168334 | ||
|
|
e3ee404bd5 | ||
|
|
57082219ea | ||
|
|
2e718af711 | ||
|
|
156abfe38b | ||
|
|
152e677b15 | ||
|
|
e4d8b75723 | ||
|
|
a70e901bea | ||
|
|
cc2ecd61e6 | ||
|
|
5d5011f66c | ||
|
|
241a258e45 | ||
|
|
5678683561 | ||
|
|
b27e86c65b | ||
|
|
1a290a15a3 | ||
|
|
9de51e16af | ||
|
|
f617f60134 | ||
|
|
54b81cbb7f | ||
|
|
a03e90fc1a | ||
|
|
a3f26d424b | ||
|
|
8ad9792abd | ||
|
|
9cc3260644 | ||
|
|
3bc6d76b84 | ||
|
|
9dedaa6098 | ||
|
|
119b49cf83 | ||
|
|
f2149048fb | ||
|
|
af39efd23b | ||
|
|
01ca8033be | ||
|
|
321617861a | ||
|
|
ffb608c854 | ||
|
|
c14543a112 | ||
|
|
fb44a264ee | ||
|
|
e0e1f90aed | ||
|
|
969d5ea450 | ||
|
|
7764863142 | ||
|
|
217c8ee19c | ||
|
|
f6b7759aa8 | ||
|
|
14ac0dc248 | ||
|
|
a322cfcbfe | ||
|
|
be37c17962 | ||
|
|
84f85bbc79 | ||
|
|
72ee57f0a8 | ||
|
|
95131d856e | ||
|
|
7092469f90 | ||
|
|
592f0c26f6 | ||
|
|
5b217ae32d | ||
|
|
5a9d67530a | ||
|
|
59eefdb052 | ||
|
|
494a45fe12 | ||
|
|
02c7910ad5 | ||
|
|
631d4647dc | ||
|
|
0f26ed3753 | ||
|
|
9c623a6fd3 | ||
|
|
70dea995b7 | ||
|
|
822f98022d | ||
|
|
df0e98d811 | ||
|
|
4a41263357 | ||
|
|
c25e85b461 | ||
|
|
9ead4ae892 | ||
|
|
e4b819a4cc | ||
|
|
081ce65596 | ||
|
|
829bd9043c | ||
|
|
bf6b0c4126 | ||
|
|
7235cf08e4 | ||
|
|
d0a8f68607 | ||
|
|
f86be2bd38 | ||
|
|
1a4e1fee12 | ||
|
|
2f491c31f9 | ||
|
|
6a5c54102e | ||
|
|
43ab6c0a83 | ||
|
|
1315b37f62 | ||
|
|
4a212760b6 | ||
|
|
0e48ea15b8 | ||
|
|
a22baebe70 | ||
|
|
b5e96c61ac | ||
|
|
d4fd675935 | ||
|
|
6505493d4e | ||
|
|
3c61a2397e | ||
|
|
1b77a36d3d | ||
|
|
8780f26308 | ||
|
|
7ddcec4941 | ||
|
|
dc1fa8d082 | ||
|
|
67deb103b0 | ||
|
|
91f723a019 | ||
|
|
1cb36da1e2 | ||
|
|
abc51a957b | ||
|
|
bc3a2b56a4 | ||
|
|
41746d1612 | ||
|
|
25d860b2b3 | ||
|
|
88dbf6d955 | ||
|
|
aace0c5836 | ||
|
|
0fdc889af6 | ||
|
|
a183adc072 | ||
|
|
0f69eec64d | ||
|
|
f3e20b670e | ||
|
|
bc71834bc3 | ||
|
|
9e35f9fb9d | ||
|
|
e09411f975 | ||
|
|
178f6ebbff | ||
|
|
73badb8a02 | ||
|
|
9d0d985b1b | ||
|
|
8d8552113a | ||
|
|
1ff00bbe50 | ||
|
|
e7b04c4afe | ||
|
|
134b0a2aa4 | ||
|
|
99538e45b9 | ||
|
|
27bbc74eaa | ||
|
|
5f09b8113a | ||
|
|
2129d8cee6 | ||
|
|
fbeff94e55 | ||
|
|
0367073d32 | ||
|
|
1c5283058b | ||
|
|
42099d4336 | ||
|
|
6b161227d8 | ||
|
|
5adac230fe | ||
|
|
bbf96de9b3 | ||
|
|
c31036dea8 | ||
|
|
c560b66df6 | ||
|
|
f408bf5312 | ||
|
|
45544791e5 | ||
|
|
89a51a63b1 | ||
|
|
72b0c9d465 | ||
|
|
954a129e01 | ||
|
|
89dcb6bdf3 | ||
|
|
777d02e7cb | ||
|
|
609bcb3044 | ||
|
|
30b1ff6f48 | ||
|
|
d690d09b84 | ||
|
|
90b233088f | ||
|
|
3ffb1c4fee | ||
|
|
29f531db23 | ||
|
|
7f657d225c | ||
|
|
4760714743 | ||
|
|
78bcc3038b | ||
|
|
4b26a0a9dc | ||
|
|
f499961e5b | ||
|
|
824bb36f8e | ||
|
|
89d98184c1 | ||
|
|
8574d6ba2d | ||
|
|
9253684387 | ||
|
|
8db1c5ab16 | ||
|
|
6eb2846313 | ||
|
|
ecb3d08445 | ||
|
|
c3f20784a8 | ||
|
|
875ac159ee | ||
|
|
a2b1a06901 | ||
|
|
06461df91b | ||
|
|
a8d4cce3e2 | ||
|
|
15eec1160d | ||
|
|
34f3db359a | ||
|
|
6dd652aced | ||
|
|
b34e86bba5 | ||
|
|
40a4c06d42 | ||
|
|
3e01e83c61 | ||
|
|
e827f7a0ef | ||
|
|
7704a75424 | ||
|
|
cf8c6912ec | ||
|
|
3f228ae092 | ||
|
|
d3e959e754 | ||
|
|
c166944c8e | ||
|
|
029ece3f17 | ||
|
|
423ea77758 | ||
|
|
bcb277c1dd | ||
|
|
f67b24cc7b | ||
|
|
d298691c17 | ||
|
|
95530a5311 | ||
|
|
839a142db0 | ||
|
|
380fac0324 | ||
|
|
2061296615 | ||
|
|
2ad808d55c | ||
|
|
330f7216fd | ||
|
|
4b1471a60d | ||
|
|
8153e54f87 | ||
|
|
f986981c12 | ||
|
|
18e36c6514 | ||
|
|
53b27eac45 | ||
|
|
fa9dd170d3 | ||
|
|
31cdfd5bbd | ||
|
|
45cd44f756 | ||
|
|
f6e55c5c7e | ||
|
|
b9eae2bb73 | ||
|
|
b16932a312 | ||
|
|
4fde22067e | ||
|
|
0eb069fa37 | ||
|
|
5ed2bf3a7d | ||
|
|
62d4af150e | ||
|
|
ee4d82e3d4 | ||
|
|
1bccf89e5c | ||
|
|
063a1dba39 | ||
|
|
c0396544c5 | ||
|
|
5340908882 | ||
|
|
6453c76750 | ||
|
|
9d6ee70301 | ||
|
|
57e8380b24 | ||
|
|
b3f99652bb | ||
|
|
da2da4cd9b | ||
|
|
5c40c64b5f | ||
|
|
22e4c423fa | ||
|
|
b8ab46662c | ||
|
|
eae7ae0ff4 | ||
|
|
ce2c42032b | ||
|
|
e77a5df6e8 | ||
|
|
1331d90ff4 | ||
|
|
6b66adff44 | ||
|
|
022778a5a7 | ||
|
|
b484998fc9 | ||
|
|
270026338f | ||
|
|
d4e73aacdd | ||
|
|
11d7b64a5e | ||
|
|
5d7426b6c9 | ||
|
|
a4cdd61fe5 | ||
|
|
5d9e75efd8 | ||
|
|
10d4f86fdf | ||
|
|
38b1e7a4dd | ||
|
|
c723d1faff | ||
|
|
e9af27c8f1 | ||
|
|
36f58d7904 | ||
|
|
27f0d2a551 | ||
|
|
5d58301e2e | ||
|
|
03ce813c32 | ||
|
|
fa2c6560fd | ||
|
|
7406785325 | ||
|
|
006eb6869a | ||
|
|
1f4bc1b80d | ||
|
|
13248ddc9b | ||
|
|
4b059201fd | ||
|
|
b748986aae | ||
|
|
de9d5337ac | ||
|
|
e377989327 | ||
|
|
7c274afd7c | ||
|
|
896edf07fe | ||
|
|
7627462593 | ||
|
|
da1fe3aa4f | ||
|
|
fa40d798e2 | ||
|
|
b566097107 | ||
|
|
c825e1aa3d | ||
|
|
111955efae | ||
|
|
dffae2c429 | ||
|
|
55a775fca7 | ||
|
|
a36f542f28 | ||
|
|
81e34d77da | ||
|
|
22636668d0 | ||
|
|
e9034a40c8 | ||
|
|
48061c3f31 | ||
|
|
56bc52756b | ||
|
|
1d0ff70908 | ||
|
|
8be91a9cc9 | ||
|
|
09b716ae79 | ||
|
|
f4c48752c2 | ||
|
|
948a3685cd | ||
|
|
579f3ae271 | ||
|
|
2ce394fbfc | ||
|
|
36c1cce00d | ||
|
|
80314f4830 | ||
|
|
31ee69100a | ||
|
|
1aac62f6f0 | ||
|
|
18327fb038 | ||
|
|
c1fc5b8086 | ||
|
|
d03ba13c0f | ||
|
|
f159529936 | ||
|
|
00f132d8ce | ||
|
|
fb3d3f46c9 | ||
|
|
8ca1e1e965 | ||
|
|
2f8bbd1746 | ||
|
|
c9e6236a87 | ||
|
|
fecff989e7 | ||
|
|
0d4628f85e | ||
|
|
1c7285cea9 | ||
|
|
ef3212021a | ||
|
|
05fefc8a7b | ||
|
|
21f2734981 | ||
|
|
ddd234b3c8 | ||
|
|
78667953f5 | ||
|
|
9519ce8e1b | ||
|
|
4155429f45 | ||
|
|
0c9d4049b3 | ||
|
|
c5765c57e7 | ||
|
|
00dbb5a5d5 | ||
|
|
17394b73ff | ||
|
|
b69a2cdd7f | ||
|
|
35345f3399 | ||
|
|
4d7b198822 | ||
|
|
4954ba3463 | ||
|
|
b563c6ac44 | ||
|
|
eed1c9d3f7 | ||
|
|
b31a758cda | ||
|
|
a878fc6a58 | ||
|
|
ab5f5802a8 | ||
|
|
95a6f56060 | ||
|
|
a3b3c9eba7 | ||
|
|
86f7a31d9a | ||
|
|
5fd1f3ca26 | ||
|
|
d075feb77d | ||
|
|
e905fa3922 | ||
|
|
8072f1cc7e | ||
|
|
5f85891e7e | ||
|
|
5861881073 | ||
|
|
4774e7b788 | ||
|
|
22ddc74784 | ||
|
|
831bd00620 | ||
|
|
589f51f4fb | ||
|
|
87d5dd8f67 | ||
|
|
0de8e63ad6 | ||
|
|
c5c543ba43 | ||
|
|
ddf6937b2b | ||
|
|
a650f8184b | ||
|
|
dc67be529d | ||
|
|
c495fccd6c | ||
|
|
a0ccf79b2b | ||
|
|
367100848a | ||
|
|
fae1b61d9e | ||
|
|
2c442585b1 | ||
|
|
352dd7937e | ||
|
|
69d0025d09 | ||
|
|
d3e9a62b21 | ||
|
|
614bd253c8 | ||
|
|
9e30d2cf15 | ||
|
|
6cfac9c823 | ||
|
|
4069204379 | ||
|
|
0cb1454067 | ||
|
|
aca280fd30 | ||
|
|
5a44870de5 | ||
|
|
a426cb5dcb | ||
|
|
9d9ab3e7e0 | ||
|
|
d0d24d42e7 | ||
|
|
a4bd4d2b38 | ||
|
|
2a0493c3eb | ||
|
|
cf52751124 | ||
|
|
40864d93d0 | ||
|
|
e37b452345 | ||
|
|
9519a89472 | ||
|
|
79404fa9d1 | ||
|
|
c20c742b60 | ||
|
|
a1adf024ba | ||
|
|
14da7b3dec | ||
|
|
df4ff373b7 | ||
|
|
3fcdc3d3e1 | ||
|
|
16bdad1b98 | ||
|
|
141e9b12c7 | ||
|
|
b89cc91b78 | ||
|
|
a4d86e9b19 | ||
|
|
29f65b8e6f | ||
|
|
958ced2d7e | ||
|
|
95e8a5ca42 | ||
|
|
0aac307d92 | ||
|
|
7e08e0ccc9 | ||
|
|
ea5f66cf92 |
No files matched your search
+246
-2
@@ -3,6 +3,12 @@ name: CI
|
||||
# 主干门禁:fmt / clippy / test / build 双平台矩阵。
|
||||
# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。
|
||||
# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。
|
||||
#
|
||||
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树,源码原样保留)。
|
||||
# 所以本文件里所有 `--workspace` 都**不再**覆盖 `unlucky-app`:CI 不再编 slint 1.18 + usvg 0.48
|
||||
# 那棵依赖树(本机单路冷编 ≈14 分钟;并行超配时表现为 `rustc` 退 1 且零诊断,
|
||||
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
|
||||
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -38,7 +44,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -49,6 +55,8 @@ jobs:
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
|
||||
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
@@ -69,7 +77,114 @@ jobs:
|
||||
libwayland-dev libxcb1-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace --all-targets
|
||||
# ---- 下载页选项区的四条像素守门(M127):先出图,再把路径喂给 `--lib` ----
|
||||
# 之前这四条靠开发者本机手动设 `ULL_DL_*_SHOT`(不设就 SKIP),而本文件里 grep 这些
|
||||
# 变量零命中 ⇒ **CI 里一次都没跑过**。空跑的守门长期等于没有,所以这两步是它们的第一处
|
||||
# 常驻调用点。(另有一道保险:变量缺失且 `CI` 已设置时,守门自己会失败而不是跳过。
|
||||
# 于是将来谁把下面出图那步重构掉,构建会红。)
|
||||
#
|
||||
# **为什么只在 Windows**:断言里有几条直接量墨迹(行高 44、版本号墨迹条数、图标药丸
|
||||
# 40×40),这些数字按本仓字体栈(Segoe UI / Consolas)标定;Linux/macOS runner 上
|
||||
# 这套字体不存在,回退字体的字高与行距一变就是假红。同一条限制也写在
|
||||
# `crates/app-iced/src/pages/download/tests/options_shot/feed.rs` 的模块文档里
|
||||
# (非 Windows 喂了图会当场失败),不靠「碰巧只配了一处」。
|
||||
- name: 出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shots="$RUNNER_TEMP/dl-shots"
|
||||
mkdir -p "$shots"
|
||||
render() { # $1=宽 $2=文件名
|
||||
cargo run -q -p unlucky-app-iced --example pages_demo -- --screen=download \
|
||||
--width="$1" --height=760 --screenshot="$shots/$2" \
|
||||
--screenshot-theme=light --software-renderer
|
||||
}
|
||||
# ready700:**不设** ULL_DL_DEMO(缺省就是 ready,还没选版本)
|
||||
render 700 ready700.png
|
||||
export ULL_DL_DEMO=options-fabric # 其余三张都是「选中版本 + 选项区展开」
|
||||
render 700 chips700.png
|
||||
render 736 compact736.png
|
||||
render 1180 wide1180.png
|
||||
for shot in ready700 chips700 compact736 wide1180; do
|
||||
test -s "$shots/$shot.png" || { echo "::error::$shot.png 没出图"; exit 1; }
|
||||
done
|
||||
ls -l "$shots"
|
||||
{
|
||||
echo "ULL_DL_READY_SHOT=$shots/ready700.png"
|
||||
echo "ULL_DL_CHIPS_SHOT=$shots/chips700.png"
|
||||
echo "ULL_DL_COMPACT_SHOT=$shots/compact736.png"
|
||||
echo "ULL_DL_OPTIONS_SHOT=$shots/wide1180.png"
|
||||
} >> "$GITHUB_ENV"
|
||||
# 单独跑一遍只为**取证**:日志里那行 `test result: ok. 4 passed` 是「守门真跑了」的
|
||||
# 直接证据(紧接着的 `--workspace` 那步会再跑一遍,热的,代价可忽略)。
|
||||
- name: 四条像素守门(已喂图,仅 Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: cargo test -p unlucky-app-iced --lib options_shot
|
||||
# 断言红的时候要先看到那张图(否则「像素不对」只有一句话,没有现场)。
|
||||
- name: 上传喂进守门的四张图(失败也传)
|
||||
if: always() && runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dl-shots
|
||||
path: ${{ runner.temp }}/dl-shots/
|
||||
if-no-files-found: warn
|
||||
# 同 clippy:`--workspace` 不含 `unlucky-app`(M118),Slint 退役树不再被 `--all-targets` 拉起。
|
||||
# 也因此**不要**再写 `--exclude unlucky-app`:它已不是成员,那面旗子只会打一条
|
||||
# `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
|
||||
#
|
||||
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
|
||||
# M144 的三条红里两条已在 docker 里定位到行,第三条 `test (windows)` 在 run 里只留下
|
||||
# `Process completed with exit code 1`——而 cargo 自己的失败码是 **101**,所以那个 1 的
|
||||
# 来源不明(包装层洗码 / 进程被杀,两说都未排除)。job log 要登录才能读(匿名 403),
|
||||
# 但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。所以这三件
|
||||
# 事各自对准一种「看不见」:
|
||||
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑了
|
||||
# 7 个 test binary 就退出),后面还有没有第二条失败完全看不到;
|
||||
# 2) 把 `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,
|
||||
# 注解是匿名侧唯一能取到的失败正文;
|
||||
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀
|
||||
# 只给码、零诊断」三种情形分开——这正是 M144 没定的那一半。
|
||||
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
log="$RUNNER_TEMP/cargo-test.log"
|
||||
# 默认 shell 带 `-e` + `pipefail`:管道里 cargo 一失败就会在 tee 之后立刻退出,
|
||||
# 下面三行永远跑不到。所以这段先关 `-e`,自己取 `PIPESTATUS[0]`(cargo 的码,
|
||||
# 不是 tee 的码)。
|
||||
set +e
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "cargo exit=$code"
|
||||
if [ "$code" -ne 0 ]; then
|
||||
# `%` 是 workflow command 的保留字符,要转成 `%25`,否则注解正文会被截断。
|
||||
grep -E '^(error(\[E[0-9]+\])?:|failures:|test result: FAILED|thread .* panicked at| *--> )' "$log" \
|
||||
| head -40 | sed 's/%/%25/g; s/^/::error::/' || true
|
||||
fi
|
||||
exit "$code"
|
||||
- name: cargo test --workspace --all-targets(windows,失败升注解)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
# M171 跨卷守卫:runner 到底有没有第二块可写卷没有核实手段,所以不赌 —— 显式声明豁免。
|
||||
# 豁免是惰性的:真探到第二块卷时它不参与判断(见 install_xdev_cases 的真值表用例)。
|
||||
ULL_XDEV_TEST_WAIVE: "1"
|
||||
run: |
|
||||
# pwsh 没有 PIPESTATUS(那是 bash 语法):拿 `$LASTEXITCODE` —— Tee-Object 是 cmdlet,
|
||||
# 不会把它改掉,所以它是 cargo 自己的码。
|
||||
$log = Join-Path $env:RUNNER_TEMP 'cargo-test.log'
|
||||
cargo test --workspace --all-targets --no-fail-fast 2>&1 | Tee-Object -FilePath $log
|
||||
$code = $LASTEXITCODE
|
||||
Write-Host "cargo exit=$code"
|
||||
if ($code -ne 0) {
|
||||
# 正则不锚 `error:` 那一支:pwsh 把原生 stderr 折成 ErrorRecord 后行首可能多一截前缀,
|
||||
# 锚死会漏行;`%` 是 workflow command 的保留字符,转成 `%25`。
|
||||
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' |
|
||||
Select-Object -First 40 |
|
||||
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
|
||||
}
|
||||
exit $code
|
||||
|
||||
build:
|
||||
name: build release (${{ matrix.os }})
|
||||
@@ -89,4 +204,133 @@ jobs:
|
||||
libwayland-dev libxcb1-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
|
||||
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
|
||||
- run: cargo build --workspace --release
|
||||
# M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本——
|
||||
# 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫
|
||||
# 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。
|
||||
- name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PY=$(command -v python3 || command -v python)
|
||||
"$PY" packaging/check_pe_subsystem.py \
|
||||
--gui target/release/unlucky-launcher-iced.exe \
|
||||
--cli target/release/ull.exe
|
||||
|
||||
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
|
||||
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
|
||||
# 启动 GUI 进程,让软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身**
|
||||
# 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。
|
||||
# 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。
|
||||
# M118(D26):这里原本绑死 Slint 的 `Window::take_snapshot`,打的是 Slint 版 bin
|
||||
# `unlucky-launcher`;`crates/app` 退出 workspace 后那个 bin 不再存在,改用 iced 版
|
||||
# `unlucky-launcher-iced` 自带的 `--screenshot` 通道(`crates/app-iced/src/shot/`)。
|
||||
# 命令行参数刻意不动:iced 的 `cli.rs` 就是照 Slint 版的口径写的,`--data-dir` 也仍被接受(忽略)。
|
||||
runtime-smoke:
|
||||
name: runtime smoke (ubuntu x64, xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
xvfb xauth \
|
||||
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 \
|
||||
fonts-dejavu-core fonts-liberation2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
|
||||
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
|
||||
# M118:bin 从 Slint 版 `unlucky-launcher` 换成 iced 版(见上方 job 注释)。
|
||||
- run: cargo build --bin unlucky-launcher-iced --locked
|
||||
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
target/debug/unlucky-launcher-iced --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
|
||||
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
|
||||
# WindowServer 会话,GUI 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
|
||||
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
|
||||
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
|
||||
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
|
||||
# M118:bin 与 Linux 那条一样,从 Slint 版换成 iced 版(`unlucky-launcher-iced`)。
|
||||
runtime-smoke-macos:
|
||||
name: runtime smoke (macos arm64, probe)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
|
||||
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --bin unlucky-launcher-iced --locked
|
||||
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
target/debug/unlucky-launcher-iced --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot-macos
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
|
||||
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
|
||||
#
|
||||
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
|
||||
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
|
||||
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
|
||||
# (r1 复审 P2-4)。
|
||||
#
|
||||
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
|
||||
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
|
||||
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
|
||||
#
|
||||
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
|
||||
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
|
||||
update-e2e:
|
||||
name: update-e2e (windows x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# 同 test 作业:跨卷 e2e 守卫在没有第二块卷的 runner 上需要显式豁免,否则会判红。
|
||||
ULL_XDEV_TEST_WAIVE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: update-e2e
|
||||
run: >
|
||||
cargo test --release -p unlucky-cli --features update-test-source
|
||||
--test update_e2e_test --test update_e2e_tamper_test
|
||||
@@ -0,0 +1,125 @@
|
||||
name: Dev Guards
|
||||
|
||||
# 日常线门禁(M133)。
|
||||
#
|
||||
# 为什么需要这个文件:本仓日常开发线是 `dev`,日常合并**不走 PR**(见 DevDocs/AGENTS.md §8 双仓纪律:
|
||||
# Gitea `dev` = 日常线)。而 ci.yml 与 guards.yml 只挂在 `push: branches: [main]` + `pull_request` 上
|
||||
# ⇒ fmt / clippy / 文件行数≤400 / 三条零依赖守门在**日常线上一次都没跑过**。第一个可见后果:
|
||||
# `packaging/README.md` 涨到 401 行、超全仓红线无人拦,直到文档普查才被发现(M131 才修回 398)。
|
||||
# 「门禁存在但日常线上永不运行」= 空跑的门禁,长期等于没有。
|
||||
#
|
||||
# 口径:**只补「快、且坏了会静默腐化」的那几道**,且不复制守门脚本:
|
||||
# - `guards` 作业直接 `uses: ./.github/workflows/guards.yml` —— 六道守门(core-no-ui /
|
||||
# presenter-no-ui / design-zero-dep / unsafe-scope / file-size / licenses)跑的是与 main 同一份脚本,
|
||||
# dev 与 main 的判据永远同源。main / PR / dispatch 的触发与作业一个字没动。
|
||||
# - `fmt` / `clippy` 在本文件里各写一份:ci.yml 的作业背后挂着 build / test / runtime-smoke 整条矩阵,
|
||||
# 整份复用等于把 dev 的成本翻倍;这两条命令与 ci.yml 逐字一致,方便对照与同步。
|
||||
#
|
||||
# 刻意**不上** dev 的(判断依据见 M133 报告,这里只记结论):
|
||||
# - 全量 `cargo test`:`clippy --all-targets` 已经把测试**代码**类型检查过一遍(「测试编译不过了」
|
||||
# 这类静默腐化会被它抓到),而「断言暂时红了」在施工中间态是常态、不是腐化;test 是 2 个 OS ×
|
||||
# 全量编译+运行,留给 main 与发布前的最终门更划算。
|
||||
# - build release / runtime-smoke:它们是「能不能发」的门,dev 每次 push 跑一遍换不到新信息。
|
||||
#
|
||||
# 成本控制(dev push 很频繁,这是硬要求):
|
||||
# 1. `concurrency` 取消**同一个 ref** 上的旧运行:连推三次只留最后一次,被取消的旧运行基本不计费。
|
||||
# 2. clippy 只在「这次推送碰过 Rust 侧文件」时跑:只改文档(*.md / LICENSE / NOTICE / .gitignore)
|
||||
# 的推送跳过编译。判据**保守**——非 push、base 不可解析(force-push / 首次推送)、diff 取不到、
|
||||
# 出现任何不在白名单里的路径,一律照跑:宁可多跑一次,不许假绿。
|
||||
# 刻意**不**用 `on.push.paths` 过滤整个工作流:file-size 正是要在「只改文档」的推送里跑的那道门
|
||||
# (README 401 行就是这么漏的),整条过滤掉等于自废武功。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
group: dev-guards-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 六道守门原样来自 guards.yml(那边加了 `workflow_call:` 触发,纯新增)。本文件不重写它们的脚本:
|
||||
# unsafe-scope 的词法器一旦有第二份副本,就会出现「dev 绿、main 红」的双真相。
|
||||
guards:
|
||||
name: 架构与合规守门(同 main 口径)
|
||||
uses: ./.github/workflows/guards.yml
|
||||
|
||||
fmt:
|
||||
name: fmt (rustfmt --check)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --all --check
|
||||
|
||||
# 只跑 ubuntu:clippy 的判据是类型层 lint,跨平台差异极少(Windows 专属行为由 main/PR 上的
|
||||
# windows-latest 槽位与 test 里的像素守门兜);两个 OS 都上会把 dev 的编译成本直接翻倍。
|
||||
clippy:
|
||||
name: clippy (ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# 要 diff 出「这次推了哪些文件」就得有 base commit;浅 clone 会让下面的判据退化成
|
||||
# 「一律照跑」(保守,但不再省成本),所以这里取全历史。
|
||||
fetch-depth: 0
|
||||
- name: 本次推送是否碰过 Rust 侧文件(只碰文档则跳过编译)
|
||||
id: scope
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
run=yes
|
||||
note="照跑:默认(未进入可判定的 push 分支)"
|
||||
if [ "$EVENT" != "push" ]; then
|
||||
note="照跑:非 push 事件(手动 dispatch)没有可信 diff"
|
||||
elif [ -z "${BEFORE:-}" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||
note="照跑:base 为首次推送(before 全 0)"
|
||||
elif ! git rev-parse --verify -q "${BEFORE}^{commit}" >/dev/null 2>&1; then
|
||||
note="照跑:base commit 不可解析(force-push / 历史被改写)"
|
||||
else
|
||||
changed=$(git diff --name-only "$BEFORE" "$SHA" 2>/dev/null || true)
|
||||
if [ -z "$changed" ]; then
|
||||
note="照跑:diff 为空(可能是增量不可得)"
|
||||
else
|
||||
# 白名单判定:只有「全部改动都落在这些非 Rust 路径上」才跳过编译。
|
||||
# 反向写法(黑名单 .rs)更省事,但漏一个路径就等于漏一次编译检查。
|
||||
others=$(printf '%s\n' "$changed" \
|
||||
| grep -vE '\.md$' \
|
||||
| grep -vxE 'LICENSE|NOTICE|\.gitignore' || true)
|
||||
if [ -z "$others" ]; then
|
||||
run=no
|
||||
note="跳过:本次只碰文档类路径 —— $(printf '%s ' $changed)"
|
||||
else
|
||||
note="照跑:本次改动了非文档路径 —— $(printf '%s ' $others)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "$note"
|
||||
echo "run=$run" >> "$GITHUB_OUTPUT"
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libwayland-dev libxcb1-dev
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
# 与 ci.yml 的 clippy 作业逐字一致(`--workspace` 不含已退役的 `unlucky-app`,见 M118)。
|
||||
- name: cargo clippy --workspace --all-targets -D warnings
|
||||
if: steps.scope.outputs.run == 'yes'
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
@@ -1,15 +1,38 @@
|
||||
name: Guards
|
||||
|
||||
# ⚠ 本文件所有 `run:` 都在 `bash -e {0}` 下跑(GitHub 未指定 shell 时的默认值),脚本里的
|
||||
# `set -uo pipefail` **关不掉 -e**:裸管道 / 裸赋值 `x=$(… | grep …)` 零命中时会把整步静默杀死,
|
||||
# 一条 `::error::` 都不打(M142 在 release-build.yml 栽过,M156 在这里补账)。探测一律 `if ! x=$(…)` 或 `$(… || true)`。
|
||||
|
||||
# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error):
|
||||
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
|
||||
# 2. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
||||
# 3. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
|
||||
# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查
|
||||
# 2. presenter-no-ui —— crates/presenter 零 UI 依赖(D26 / M85):Slint 版与 iced 版共用的宿主层
|
||||
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
|
||||
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
||||
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
|
||||
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处)
|
||||
#
|
||||
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
|
||||
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
|
||||
# ① core-no-ui / presenter-no-ui / design-zero-dep —— 「manifest 文本 + `cargo tree -p <crate>`」
|
||||
# 口径,被守的 crate 仍是 workspace 成员,依赖树与排除前逐行一致;
|
||||
# ② file-size / unsafe-scope —— 走 `git ls-files` / `git grep`,与构建图无关,**仍然覆盖
|
||||
# `crates/app/**`**(源码还在仓库里,就还得守它的行数与 unsafe 落点);
|
||||
# ③ licenses —— 唯一走 workspace 依赖图的守卫。排除后 Slint 那棵树不再进图(实测 `cargo deny
|
||||
# list` 里 `slint*` / `i-slint-*` 为 0 个),这与「不再随任何产物发布」一致;代价是它的许可证
|
||||
# 不再被本门禁审 —— 真要重新纳入发布,得先把它加回 workspace,门禁随之恢复。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
# M133:日常线 `dev` 复用**同一份**守门脚本(`.github/workflows/dev-guards.yml` 里
|
||||
# `uses: ./.github/workflows/guards.yml`)。这是**纯新增的触发方式**:上面三条与下面全部作业
|
||||
# 一字未动,main / PR / dispatch 的触发与判据与加这行之前逐字相同。
|
||||
# 为什么复用而不是复制:判据只能有一份。两份 unsafe 词法器(110 行状态机)各自演进,迟早出现
|
||||
# 「dev 绿、main 红」的双真相,那时 dev 门禁就变成了装饰品。
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -35,7 +58,11 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||||
tree=$(cargo tree -p unlucky-core --edges normal --prefix none)
|
||||
# 与下方 presenter / design 的 `if ! tree=$(…)` 对称:cargo tree 自己失败(改名 / 解析失败)时裸赋值会被 -e 静默杀死,只剩 exit code 1 无注解。
|
||||
if ! tree=$(cargo tree -p unlucky-core --edges normal --prefix none); then
|
||||
echo "::error::cargo tree -p unlucky-core 执行失败 —— crate 是否仍叫 unlucky-core?"
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then
|
||||
echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许"
|
||||
@@ -43,6 +70,286 @@ jobs:
|
||||
fi
|
||||
echo "OK: unlucky-core 依赖树零 UI"
|
||||
|
||||
# presenter 是 Slint 版(crates/app)与 iced 版(crates/app-iced)共用的唯一业务层(D26)。
|
||||
# 一旦 slint/iced/winit 被拉进来,分层就废了,而编译与单测仍会全绿 —— 所以必须由 CI 守。
|
||||
# M118 之后 Slint 版已被 workspace `exclude`,但本条**照样守得住**:它查的是 presenter 自己的
|
||||
# manifest 与 `cargo tree -p unlucky-presenter`,与「谁在消费 presenter」无关;依赖树里一旦出现
|
||||
# slint/iced/winit 仍会当场红。名单里的 slint 继续留着,就是为了防「把 UI 依赖偷偷塞进宿主层」。
|
||||
# 与 core-no-ui 同样是「依赖表 + cargo tree」双查,名单保持一致;脚本刻意复制而非抽公共脚本,
|
||||
# 理由是 CI 守门脚本自身挂掉的排查成本远高于这几行重复。
|
||||
presenter-no-ui:
|
||||
name: presenter 零 UI 守门
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: 依赖表直查(crates/presenter/Cargo.toml 的依赖表不得出现 UI 框架)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||||
# 与 core-no-ui 的整文件 grep 不同:本 crate 的 description 合法地写着
|
||||
# 「Slint 版与 iced 版共用」,整文件 grep 会把它当越线。所以先按表头切出依赖表
|
||||
# (含 target.*.dependencies 与 dev/build 表),再去掉注释行,只查键名。
|
||||
# 子表写法 `[dependencies.slint]` / `[target.'cfg(windows)'.dependencies.slint]`
|
||||
# 是合法的等长写法,且表头本身带着包名,所以表头也要一并落进检查范围——
|
||||
# 否则「子表 + optional + 非默认 feature」这种组合能同时骗过依赖表与 cargo tree。
|
||||
awk '
|
||||
/^[[:space:]]*\[/ {
|
||||
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
|
||||
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
|
||||
next
|
||||
}
|
||||
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
|
||||
' crates/presenter/Cargo.toml > /tmp/presenter-deps.txt
|
||||
cat /tmp/presenter-deps.txt
|
||||
if grep -iE "$forbidden" /tmp/presenter-deps.txt; then
|
||||
echo "::error file=crates/presenter/Cargo.toml::presenter 不得依赖 UI 框架(D26)。窗口创建 / 渲染 / 图片解码属于 app 与 app-iced 侧;需要跨前端共用的类型请放进 presenter 的 dto。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: crates/presenter/Cargo.toml 依赖表无 UI 依赖"
|
||||
- name: 依赖树泄漏检查(cargo tree -p unlucky-presenter,排除自身行)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm'
|
||||
# 注意:cargo tree 命中 UI 框架时**退出码仍是 0**,所以只能抓输出再判断。
|
||||
if ! tree=$(cargo tree -p unlucky-presenter --edges normal --prefix none); then
|
||||
echo "::error::cargo tree -p unlucky-presenter 执行失败 —— crate 是否仍叫 unlucky-presenter?"
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
# 「零命中」本来就该是绿的正路,但裸赋值会把 grep 的 exit 1 交给 -e(见文件头):
|
||||
# M156 之前这里每次零命中都整步红且零注解 —— presenter-no-ui 自 M89 引入起从未绿过。
|
||||
hits=$(printf '%s\n' "$tree" | grep -vE '^unlucky-presenter v' | grep -iE "$forbidden" || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "::error::presenter 的依赖树出现 UI 框架(传递依赖也不允许),本次命中:"
|
||||
printf '%s\n' "$hits" | sed 's/^/ - /'
|
||||
echo "::error::presenter 是 Slint 版与 iced 版共用的宿主层:把窗口 / 渲染 / 图片解码留在 app 侧,presenter 只持有与框架无关的 DTO、状态 reducer 与 host 装配。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: unlucky-presenter 依赖树零 UI"
|
||||
|
||||
# unlucky-design 是「纯数据 + 纯函数、零第三方依赖」的设计令牌层,这是 README 明写的卖点
|
||||
# (连 serde 都不加),但目前只靠人肉守。零依赖的判据比 UI 名单更硬:依赖树只允许有自己一行。
|
||||
# 注意 `cargo tree --edges normal` 看不到 dev/build 依赖,所以另设一步按表头切依赖表逐个查空。
|
||||
design-zero-dep:
|
||||
name: design 零第三方依赖守门
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: 依赖表必须为空(dependencies / dev / build 表一律不许有键,含子表写法)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# design 承诺的是「零第三方依赖」,不分 normal / dev / build —— 而 cargo tree
|
||||
# --edges normal 对 dev/build 是瞎的。所以先按表头切出全部依赖表(含
|
||||
# `[dependencies.serde]` 这类子表写法,表头本身携带包名),再看是否为空。
|
||||
awk '
|
||||
/^[[:space:]]*\[/ {
|
||||
in_deps = ($0 ~ /^[[:space:]]*\[(target\..*\.)?(dependencies|dev-dependencies|build-dependencies)(\.[^]]*)?\][[:space:]]*$/)
|
||||
if (in_deps && $0 ~ /\.[^]]*\]/) printf "%d:%s\n", NR, $0
|
||||
next
|
||||
}
|
||||
in_deps && !/^[[:space:]]*#/ && !/^[[:space:]]*$/ { printf "%d:%s\n", NR, $0 }
|
||||
' crates/design/Cargo.toml > /tmp/design-deps.txt
|
||||
if [ -s /tmp/design-deps.txt ]; then
|
||||
cat /tmp/design-deps.txt
|
||||
echo "::error file=crates/design/Cargo.toml::unlucky-design 的依赖表必须为空(normal / dev / build 都算),本次命中:"
|
||||
sed 's/^/ - /' /tmp/design-deps.txt
|
||||
echo "::error::design 是纯数据 + 纯函数的设计令牌,README 对外承诺零第三方依赖(连 serde 都不加):色彩 / 排版 / 形状 / 高程 / 动效请不要引入任何 crate,测试与构建辅助依赖同样不许加。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: crates/design/Cargo.toml 依赖表全空(含 dev / build)"
|
||||
- name: 依赖树只允许出现自身(cargo tree -p unlucky-design --edges normal)
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if ! tree=$(cargo tree -p unlucky-design --edges normal --prefix none); then
|
||||
echo "::error::cargo tree -p unlucky-design 执行失败 —— crate 是否仍叫 unlucky-design?"
|
||||
exit 1
|
||||
fi
|
||||
echo "$tree"
|
||||
# 空行不算,剩下的每一行都必须是自己:正常时整个输出只有一行
|
||||
# `unlucky-design v0.1.0 (/path/to/crates/design)`。
|
||||
lines=$(printf '%s\n' "$tree" | grep -cE '[^[:space:]]' || true)
|
||||
others=$(printf '%s\n' "$tree" | grep -vE '^[[:space:]]*$' | grep -vE '^unlucky-design v' || true)
|
||||
if [ -n "$others" ] || [ "$lines" -ne 1 ]; then
|
||||
echo "::error file=crates/design/Cargo.toml::unlucky-design 必须零第三方依赖,本应只有自身一行,实际 $lines 行;越线的包:"
|
||||
printf '%s\n' "$others" | sed 's/^/ - /'
|
||||
echo "::error::design 是纯数据 + 纯函数的设计令牌:色彩 / 排版 / 形状 / 高程 / 动效请不要引入 serde、rand、once_cell 等任何 crate。序列化与运行时能力放到消费方(app / app-iced / presenter)。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
||||
|
||||
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
|
||||
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
|
||||
# 判据是**集合相等**而不是「黑名单命中」:
|
||||
# - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
|
||||
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
|
||||
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
|
||||
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
|
||||
unsafe-scope:
|
||||
name: unsafe 落点守门(集合必须恰好等于期望)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
|
||||
run: |
|
||||
set -uo pipefail
|
||||
EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs'
|
||||
# 扫描口径(M91 r3):一个小词法器,而不是正则。
|
||||
#
|
||||
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
|
||||
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
|
||||
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
|
||||
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
|
||||
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
|
||||
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
|
||||
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
|
||||
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
|
||||
#
|
||||
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
|
||||
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
|
||||
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
|
||||
#
|
||||
# 仍然抓不住的(已知边界,别当全覆盖):
|
||||
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
|
||||
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
|
||||
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
|
||||
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
|
||||
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
|
||||
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
|
||||
#
|
||||
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
|
||||
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
|
||||
# 红 #[doc = r#"a "b"#] 后跟真 allow
|
||||
# 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow
|
||||
# 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow
|
||||
# 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow
|
||||
# 红 以上三者的 CRLF 版本
|
||||
# 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报)
|
||||
scan() {
|
||||
awk -v path="$1" '
|
||||
# ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ----
|
||||
# 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进,
|
||||
# r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内
|
||||
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
|
||||
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
|
||||
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
|
||||
#
|
||||
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
|
||||
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
|
||||
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
|
||||
# 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。
|
||||
function step(s, n, i, c, nx, j, h, ok) {
|
||||
c = substr(s, i, 1)
|
||||
g_plain = 0
|
||||
if (g_state == 1) { if (c == "\n") g_state = 0; return i }
|
||||
if (g_state == 2) {
|
||||
if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 }
|
||||
if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 }
|
||||
return i
|
||||
}
|
||||
if (g_state == 3) {
|
||||
if (c == "\\") return i + 1
|
||||
if (c == "\"" || c == "\n") g_state = 0
|
||||
return i
|
||||
}
|
||||
if (g_state == 4) {
|
||||
if (c == "\"") {
|
||||
ok = 1
|
||||
for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
|
||||
if (ok) { g_state = 0; return i + g_hashes }
|
||||
}
|
||||
return i
|
||||
}
|
||||
if (g_state == 5) {
|
||||
if (c == "\\") return i + 1
|
||||
if (c == g_q || c == "\n") g_state = 0
|
||||
return i
|
||||
}
|
||||
if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 }
|
||||
if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 }
|
||||
if (c == "\"") { g_state = 3; return i }
|
||||
if (c == g_q) {
|
||||
nx = substr(s, i + 1, 1)
|
||||
if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i }
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
if (c == "r" || c == "b") {
|
||||
j = i + 1
|
||||
if (c == "b") {
|
||||
if (substr(s, j, 1) == "\"") { g_state = 3; return j }
|
||||
if (substr(s, j, 1) == g_q) { g_state = 5; return j }
|
||||
if (substr(s, j, 1) != "r") { g_plain = 1; return i }
|
||||
j++
|
||||
}
|
||||
h = 0
|
||||
while (substr(s, j, 1) == "#") { h++; j++ }
|
||||
if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j }
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
function lex( s, line, n, i, j, k, ch, adepth, attr) {
|
||||
s = ""
|
||||
while ((getline line < path) > 0) s = s line "\n"
|
||||
close(path)
|
||||
n = length(s)
|
||||
g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0
|
||||
for (i = 1; i <= n; i++) {
|
||||
i = step(s, n, i)
|
||||
if (!g_plain || substr(s, i, 1) != "#") continue
|
||||
j = i + 1
|
||||
if (substr(s, j, 1) == "!") j++
|
||||
if (substr(s, j, 1) != "[") continue
|
||||
# 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。
|
||||
# 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。
|
||||
attr = ""; adepth = 0; k = j
|
||||
while (k <= n) {
|
||||
k = step(s, n, k)
|
||||
if (g_plain) {
|
||||
ch = substr(s, k, 1)
|
||||
if (ch == "[") adepth++
|
||||
else if (ch == "]") adepth--
|
||||
attr = attr ch
|
||||
if (adepth <= 0) break
|
||||
}
|
||||
k++
|
||||
}
|
||||
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1
|
||||
i = k
|
||||
}
|
||||
return g_found
|
||||
}
|
||||
BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) }
|
||||
'
|
||||
}
|
||||
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
|
||||
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
|
||||
# `git grep` 零匹配 exit 1 + pipefail → 裸赋值同样会被 -e 静默杀死(见文件头)。
|
||||
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||||
if scan "$f"; then printf '%s\n' "$f"; fi
|
||||
done | LC_ALL=C sort || true)
|
||||
echo "期望(恰好这两处,集合相等):$EXPECTED"
|
||||
echo "实际扫到的集合:"
|
||||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||||
if [ "$actual" != "$EXPECTED" ]; then
|
||||
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
|
||||
if [ -n "$extra" ]; then
|
||||
echo "::error::多出来的 unsafe 落点(不在期望集合里):"
|
||||
printf '%s\n' "$extra" | sed 's/^/ - /'
|
||||
fi
|
||||
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
|
||||
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
|
||||
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
|
||||
fi
|
||||
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
|
||||
|
||||
file-size:
|
||||
name: 文件行数 ≤400 守门
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,13 +364,19 @@ jobs:
|
||||
case "$f" in
|
||||
LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;;
|
||||
CHANGELOG*|*/CHANGELOG*) continue ;;
|
||||
# README 是「对外介绍」,不是可拆分的模块:拆成多份反而更难读,用户令
|
||||
# (2026-11-05)明确「不用 400 行红线压它,但要保证介绍不臃肿、逻辑完备」。
|
||||
# 只豁免仓库根的 README*.md;docs/ 下的长文仍按模块拆分。
|
||||
README.md|README.*.md) continue ;;
|
||||
esac
|
||||
n=$(wc -l < "$f")
|
||||
if [ "$n" -gt "$LIMIT" ]; then
|
||||
echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json')
|
||||
# 覆盖范围含脚本(*.py/*.sh):packaging/ 下的构建辅助脚本同样受 400 行约束;
|
||||
# Cargo.lock 等机器生成文件刻意不在列(否则一生成就超限)
|
||||
done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json' '*.py' '*.sh')
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "共 $failed 个文件超限"
|
||||
exit 1
|
||||
@@ -78,4 +391,9 @@ jobs:
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
# 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管
|
||||
# M118:cargo-deny 走 **workspace 依赖图**,`crates/app` 被 exclude 后 Slint 那棵树不再进图
|
||||
# (实测 `cargo deny list` 里 `slint*` / `i-slint-*` 命中 0 个),所以本门禁不再审 Slint 的
|
||||
# RF-2.0 许可 —— 与「它不再随任何产物发布」一致。这不是回退:`deny.toml` 里为 Slint 留的
|
||||
# allow 条目**故意没删**(删它得先确认没有别的路径重新引入),当前等于一条死条目。
|
||||
# 若将来把 `crates/app` 加回 workspace,本门禁的覆盖会自动恢复。
|
||||
command: check licenses bans sources
|
||||
@@ -0,0 +1,400 @@
|
||||
name: Release / build(可复用)
|
||||
|
||||
# Release 流水线的**构建侧**(M145 拆分:`release.yml` 与 `packaging/README.md` 都顶到 400 行红线,下一处改动必越线)。
|
||||
# M145 时本文件 = 拆分前 `.github/workflows/release.yml` 的 `build` job **逐字搬家**,改动只有四个矩阵键换成同名入参:
|
||||
# `matrix.slot` → `inputs.slot`、`matrix.os` → `inputs.os`、`matrix.build_dir` → `inputs.build_dir`、
|
||||
# `matrix.rustflags` → `inputs.rustflags`。
|
||||
# **M164 改了两步**(只这两步):① `Windows ARM64 工具链体检` → `工具链取证`(不再 exit 1,判定权交回编译器;
|
||||
# 理由见该步上方的长注释);② `cargo build` 步给 arm64 槽位显式备齐 target / `LIB`+`INCLUDE` / arm64 `link.exe`。
|
||||
# **M184 加了一步**:`linux-x64` 的 xvfb 运行时冒烟(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定)——
|
||||
# 这一档进了 `PUBLISH_SLOTS`,「发出去的包真能跑」从此由 CI 每轮证明;判据只在 `linux-x64` 槽位上跑(arm64 没有
|
||||
# 对应的运行时证据,不给它加一条没验证过的门禁)。同一轮把 arm64 的失败收尾注解按槽位分支。
|
||||
# 矩阵**仍由调用方** `release.yml` 的 `plan` job 生成并在外层展开(矩阵不能带 `if`,`strategy` 可读 `needs`),
|
||||
# 本文件只跑**单个槽位**,不持有槽位表 —— 改槽位只改 `release.yml` 里那一处。
|
||||
# `env` 必须在被调方重复声明:**被调工作流不继承调用方的 workflow 级 `env`**(CARGO_TERM_COLOR / RUST_BACKTRACE
|
||||
# 都是构建产物的实参:前者只影响日志着色,后者决定 panic 是否打回溯)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slot:
|
||||
description: "槽位键,同 plan matrix 的 slot(如 windows-x64 / linux-arm64)"
|
||||
type: string
|
||||
required: true
|
||||
os:
|
||||
description: "runner 标签,同 plan matrix 的 os(如 windows-latest / windows-11-arm)"
|
||||
type: string
|
||||
required: true
|
||||
build_dir:
|
||||
description: "产物根,同 plan matrix 的 build_dir(六槽位一律 target/release)"
|
||||
type: string
|
||||
required: true
|
||||
rustflags:
|
||||
description: "RUSTFLAGS,同 plan matrix 的 rustflags(两个 Windows 槽位为 -C target-feature=+crt-static,其余空)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
# (M149)微软登录用的 Azure 公共客户端 id(公共客户端 id 随二进制一起分发,不按高密级处理,但**不进 git
|
||||
# 文件**:真值放仓库 Secret,用户在仓库设置里录入)。`required: false` 是刻意的——调用方没传(本地、
|
||||
# 没有 runner 的镜像站、以及 Secret 尚未录入时)求值为空串,`client_id.rs` 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id,官方构建才烘。**调用方必须显式传**(`secrets: inherit` 或按名传);
|
||||
# 没传时这里是空,不会报错。
|
||||
secrets:
|
||||
ULL_MSA_CLIENT_ID:
|
||||
description: "Azure 公共客户端 id,烘进发布二进制(构建期 option_env!);留空 = 不烘"
|
||||
required: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# 与拆分前 build job 拿到的凭据范围一致(可复用工作流的权限只取调用方与本文件的**交集**,故两边都声明 read)。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (${{ inputs.slot }})
|
||||
runs-on: ${{ inputs.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / wgpu 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libxkbcommon-dev libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# (M141/M142 → **M164 判定权移交**)windows-arm64 跑在**原生** `windows-11-arm` 上。本步**只取证,不判定**:
|
||||
# M141/M142 的版本用 `find` 猜 SDK 的 `um/arm64/kernel32.lib` 在不在,猜缺就 `exit 1` —— 结果 v0.2.1~v0.2.3
|
||||
# 五轮全红在**这一步**、下面 `cargo build` 一次都没跑过(第 7 步永远 skipped),"arm64 修不好"始终是探针给的
|
||||
# 结论、不是编译器给的。而它跟自己打的快照自相矛盾:v0.2.3 的注解原文是
|
||||
# `[arm64sdk] SDK Lib 版本→um 架构: 10.0.22621.0→um:[arm,arm64,x64,x86,] 10.0.26100.0→um:[arm64,x64,x86,]`
|
||||
# 同一份注解里却报 `kernel32=缺失`(同一个 `find` 模式在本地合成目录树上命中,故这条路本身不可靠)。
|
||||
# M164 把红绿交回编译器与链接器:本步只打印实况(vswhere 组件查询 / 目录里 .lib **计数** / arm64 link.exe
|
||||
# 路径),一条 `::error::` 都不写、永不 `exit 1`;红不红由 `cargo build` 与静态 CRT 导入表门禁决定。
|
||||
# 也不再自动补装:runner 镜像是 Windows 11 Arm64 + Visual Studio 2026(`windows-11-arm` 自 2026-09 切到 VS2026,
|
||||
# VS 在 `C:\Program Files\Microsoft Visual Studio\18\Enterprise`),其镜像清单
|
||||
# (actions/runner-images 的 `images/windows/Windows11-VS2026-Arm64-Readme.md`)里 `VC.Tools.ARM64`、
|
||||
# `Windows11SDK.26100`、`Windows11SDK.22621` 三件都已装好;M142 那次 `setup.exe modify` 反倒返回非 0。
|
||||
# 注解仍每条一行(job 日志下载要 token,check-run 注解不用;多行会被拼成一行截断)。
|
||||
- name: Windows ARM64 工具链取证(只打印实况,红绿交给编译器)
|
||||
if: inputs.slot == 'windows-arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
set +e; set -uo pipefail # -e 由 runner 命令行注入,只能显式关
|
||||
say() { printf '::%s::[arm64sdk] %s\n' "$1" "$2"; return 0; }
|
||||
shopt -s nullglob # 计数走 glob 不走 find:find 的管道 + errexit 正是 M141 的死因
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
[ -x "$vswhere" ] || say warning "找不到 vswhere($vswhere):下面会是 0 命中,那不代表工具链缺东西"
|
||||
# ① 组件:用 vswhere 自己的 `-requires`(权威查询),不拿「某个文件名存在」当替身。
|
||||
for comp in Microsoft.VisualStudio.Component.VC.Tools.ARM64 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.26100 \
|
||||
Microsoft.VisualStudio.Component.Windows11SDK.22621; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -requires "$comp" -property installationPath 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then say notice "组件 $comp: 已装($hit)"
|
||||
else say warning "组件 $comp: 未装 / 这台 VS 不认这个 id(继续看下面的实际 .lib 计数)"; fi
|
||||
done
|
||||
# ② 实况计数:`+crt-static` 要 VC 的 `lib/arm64/libcmt.lib`,链接要 SDK 的 `um/arm64/*.lib`。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
hits=( "$root"/*/*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib )
|
||||
say notice "VC $root → lib/arm64/libcmt.lib ${#hits[@]} 个${hits[0]:+(首个 ${hits[0]})}"
|
||||
done
|
||||
for root in "C:/Program Files (x86)/Windows Kits/10/Lib" "C:/Program Files/Windows Kits/10/Lib"; do
|
||||
[ -d "$root" ] || { say notice "SDK $root:不存在"; continue; }
|
||||
for ver in "$root"/*/; do
|
||||
libs=( "$ver"um/arm64/*.lib )
|
||||
say notice "SDK $(basename "$ver") → um/arm64/*.lib ${#libs[@]} 个"
|
||||
done
|
||||
done
|
||||
# ③ arm64 链接器候选:宿主 arm64 的优先(原生),x64 托管的次之。下面 cargo 步会显式导出它。
|
||||
for p in 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
hit=""
|
||||
[ -x "$vswhere" ] && hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
say notice "link.exe 候选 $p → ${hit:-未命中}"
|
||||
done
|
||||
echo "取证完毕:本步不判红;arm64 的结论只看下面的 cargo build / 链接 / 导入表门禁"
|
||||
|
||||
# M118(D26):**Slint 版停发** —— GUI 只有 `unlucky-launcher-iced`(`crates/app` 已 exclude,不进 workspace)。
|
||||
# 产物名由 make_artifacts.py 的 `asset_stem()` 生成 `<名字>_<版本号>_<平台>_<架构>`;不传 `--target`(M140 起全原生)。
|
||||
# (M149)`ULL_MSA_CLIENT_ID` 经 `option_env!` 烘进二进制(`crates/core/src/auth/microsoft/client_id.rs`):
|
||||
# 官方产物自带微软登录所需的配置,装完即可登录;运行期同名环境变量仍优先于它,可覆盖。
|
||||
# 值来自仓库 Secret(不进 git 文件);Secret 没配时这里是空串,client_id.rs 把空白当「没配」,
|
||||
# 于是非官方构建不烘 id。想自己烘一份就从源码构建:`ULL_MSA_CLIENT_ID=<id> cargo build`。
|
||||
# (M164)arm64 槽位在编译前把工具链**显式**备齐,不再依赖「runner 反正自带」这种默契:
|
||||
# ① `rustup target add aarch64-pc-windows-msvc`:宿主机本来就是 aarch64-pc-windows-msvc,这句在原生
|
||||
# runner 上是幂等 no-op;它把「缺 std」这类失败挡在编译前(缺 target 时 rustc 只会说找不到 crate std);
|
||||
# ② MSVC 的 `LIB` / `INCLUDE` 用微软自己的 `vcvarsall.bat arm64` 取(**不手搓路径**:少一个目录就是
|
||||
# LNK1181),本步 `export` 一次、同时写进 `$GITHUB_ENV` 供下游 dumpbin / 打包步;
|
||||
# ③ arm64 的 `link.exe` 用 vswhere 定位后显式导出 `CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`
|
||||
# (Hostarm64 原生优先、Hostx64 次之)。**②必须先于③**:只覆盖链接器而不给 LIB,rustc 会在
|
||||
# `link.exe` 里撞 `LNK1181: cannot open input file 'kernel32.lib'`——那正是我们要定位的错误,不是要制造的错误。
|
||||
# ①②③ 全是「定位不到就打印原因继续」,不用启发式决定红绿:真缺工具链时让链接器自己报出来。
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ inputs.rustflags }}
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "host=$(rustc -vV | sed -n 's/^host: //p') slot=$SLOT build_dir=$BUILD_DIR"
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
rustup target add aarch64-pc-windows-msvc
|
||||
rustup target list --installed
|
||||
vswhere="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
if [ -x "$vswhere" ]; then
|
||||
# ② MSVC 的 LIB/INCLUDE。**别把 vcvarsall 的路径直接塞给 `cmd //c "..."`**:MSYS 会把参数里的
|
||||
# `"` 改写成 `\"` 再交给 cmd.exe,而 cmd 的 /c 引号剥离接不住这一层,只会报
|
||||
# `'\"C:\...\vcvarsall.bat\"' 不是内部或外部命令` —— 结果 LIB/INCLUDE 全空、只剩 `set` 的空输出
|
||||
# (M164 复审实测)。故本步改为往 `$RUNNER_TEMP` 写一个 **CRLF** 的 wrapper .bat,引号留在
|
||||
# .bat 自己的 `call "..."` 里(由 cmd 解析,路径带空格也没事),`cmd //c` 只收**不含引号**的
|
||||
# 相对文件名(先 cd 进去),彻底绕开 MSYS 的引号改写。
|
||||
tmp=${RUNNER_TEMP:-${TEMP:-/tmp}}
|
||||
mkdir -p "$tmp"
|
||||
vcvars=""
|
||||
# `-find` 是**单值**参数且官方文档给的是反斜杠形态:两种形态各试一次(与下面 dumpbin 步同规矩),
|
||||
# 只赌一种就等于再写一个「静默退化成 warning」。
|
||||
for pat in 'VC\Auxiliary\Build\vcvarsall.bat' 'VC/Auxiliary/Build/vcvarsall.bat'; do
|
||||
vcvars=$("$vswhere" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$vcvars" ] && break
|
||||
done
|
||||
dump=""
|
||||
if [ -n "$vcvars" ]; then
|
||||
# CRLF 是必须的:cmd 解析只有 LF 的 .bat 会漏行/报错。
|
||||
{ printf '@echo off\r\n'
|
||||
printf 'call "%s" arm64 >nul 2>&1\r\n' "${vcvars//\//\\}"
|
||||
printf 'set\r\n'; } > "$tmp/ull-msvc-arm64.bat"
|
||||
dump=$(cd "$tmp" && cmd //c ull-msvc-arm64.bat 2>/dev/null | tr -d '\r') || dump=""
|
||||
echo "wrapper: $tmp/ull-msvc-arm64.bat ← $vcvars(set 输出 ${#dump} 字符)"
|
||||
fi
|
||||
for key in LIB INCLUDE; do
|
||||
value=$(printf '%s\n' "$dump" | sed -n "s/^${key}=//p" | head -n1)
|
||||
if [ -n "$value" ]; then
|
||||
export "$key=$value"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_ENV"
|
||||
echo "env $key=${value:0:90}…(${#value} 字符)"
|
||||
else
|
||||
echo "::warning::[arm64] 没拿到 $key(vcvarsall=${vcvars:-未定位}):留空交给 rustc 自己探测 VS"
|
||||
fi
|
||||
done
|
||||
found=""
|
||||
for p in 'VC\Tools\MSVC\*\bin\Hostarm64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostarm64/arm64/link.exe' \
|
||||
'VC\Tools\MSVC\*\bin\Hostx64\arm64\link.exe' 'VC/Tools/MSVC/*/bin/Hostx64/arm64/link.exe'; do
|
||||
[ -n "$found" ] && break
|
||||
hit=$("$vswhere" -latest -products '*' -find "$p" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$hit" ] && found=${hit//\\//}
|
||||
done
|
||||
if [ -n "$found" ]; then
|
||||
export CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER="$found"
|
||||
printf 'CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER=%s\n' "$found" >> "$GITHUB_ENV"
|
||||
echo "linker: $found"
|
||||
else
|
||||
echo "::warning::[arm64] 没定位到 arm64 link.exe:让 rustc 自己找(真的失败时错误里会带路径)"
|
||||
fi
|
||||
else
|
||||
echo "::warning::[arm64] 没有 vswhere:target 已加,链接器交给 rustc 自己探测"
|
||||
fi
|
||||
fi
|
||||
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
|
||||
# 产物清单只给 windows-arm64 打:两档已发布的 x64(windows-x64 / linux-x64)这一步零变化,不额外给它们
|
||||
# 加失败面(linux-x64 的证据在它自己那条 xvfb 运行时冒烟步里)。
|
||||
if [ "$SLOT" = "windows-arm64" ]; then
|
||||
ls -l "$BUILD_DIR"/unlucky-launcher-iced* "$BUILD_DIR"/ull* || true
|
||||
fi
|
||||
|
||||
# (M190)版本资源门禁:**读产物字节**,不信构建日志。`build.rs` 里写了 `CompanyName` 不等于
|
||||
# 它进了 PE——资源在链接期被丢掉、或被后处理覆盖,都只有读回来才看得见。判据(字段值、四段式
|
||||
# 版本号、FIXEDFILEINFO 的 dwFileVersion)全在 `packaging/check_pe_version.py`,本机可跑。
|
||||
# 只在 Windows 槽位跑:Linux/macOS 产物没有 PE 头,那边 `build.rs` 整段不执行(见
|
||||
# `packaging/pe_version.rs` 文件头)。**工具缺失也 fail**:找不到 python 就等于门禁失效。
|
||||
- name: PE 版本资源读回(CompanyName / ProductName / 版权 / 版本号真的在产物里)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PY=$(command -v python3 || command -v python)
|
||||
if [ -z "$PY" ]; then echo "::error::找不到 python,PE 版本资源门禁无法运行(门禁不降级)"; exit 1; fi
|
||||
# 版本号与 Cargo.toml **同源**:`[workspace.package] version = "x.y.z"`。
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
||||
echo "expect-version=$version"
|
||||
"$PY" packaging/check_pe_version.py "$BUILD_DIR/unlucky-launcher-iced.exe" "$BUILD_DIR/ull.exe" --expect-version "$version"
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 校验的是**构建产物**(iced bin 原始名);对外名 `unlucky-launcher` 由 make_artifacts.py 落盘时才换上。
|
||||
EXE: ${{ inputs.build_dir }}/unlucky-launcher-iced.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真生效只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 就是还得装可再发行包,fail。
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级):① vswhere ② find 兜底(两个 VS 根都扫)③ PATH
|
||||
# (排最后是刻意的:最不可控)。dumpbin 在 MSVC 的**宿主**工具目录里:x64 runner 是 `Hostx64/x64`,arm64
|
||||
# runner(M140)是 `Hostarm64/arm64`(反向不行——x64 上挑 Hostarm64 是跑不起来的二进制)。
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe')
|
||||
if [ "${RUNNER_ARCH:-X64}" = "ARM64" ]; then
|
||||
pats=('VC/Tools/MSVC/*/bin/Hostarm64/arm64/dumpbin.exe' "${pats[@]}")
|
||||
fi
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# `-find` 是**单值**参数:一次传多个 pattern 只有一个生效(3.x 取最后一个 —— 老写法里 Hostarm64 因此永远
|
||||
# 轮不到),故每个 pattern 各起一次定位器,不赌它取哪个。反斜杠形态优先(官方文档即此形式),再试正斜杠。
|
||||
for p in "${pats[@]}"; do
|
||||
for pat in "${p//\//\\}" "$p"; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pat" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=${hit//\\//}; VIA="vswhere"; break 2; fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),固定层数的 glob 会踩空,按模式扫。
|
||||
find_pat=()
|
||||
for p in "${pats[@]}"; do find_pat+=(-o -path "*/$p"); done
|
||||
DUMPBIN=$(find "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio" \
|
||||
-maxdepth 11 -type f \( "${find_pat[@]:1}" \) 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$DUMPBIN" ]; then VIA="find"; fi
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then DUMPBIN=$hit; VIA="PATH"; fi
|
||||
fi
|
||||
# 工具缺失同样 fail:"找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"; exit 1; fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
||||
fi
|
||||
|
||||
- name: PE 子系统门禁(GUI=WINDOWS_GUI/2,CLI=CONSOLE/3;判据与理由见 packaging/README.md §15)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
PY=$(command -v python3 || command -v python)
|
||||
"$PY" packaging/check_pe_subsystem.py --gui "${{ inputs.build_dir }}/unlucky-launcher-iced.exe" --cli "${{ inputs.build_dir }}/ull.exe"
|
||||
|
||||
# (M184)linux-x64 的**运行时门禁**:这一档已进 PUBLISH_SLOTS,所以「发出去的包真能开窗渲出一帧」得由 CI 每轮证明,
|
||||
# 不能只靠一次性的本机验收(证据与实测输出见 `packaging/docs/linux-evidence.md`)。
|
||||
# **只在 linux-x64 槽位上跑**:判据是按 x64 软渲染实测标定的;`linux-arm64` 上这条链一次都没验过(没有 arm64 的
|
||||
# xvfb 证据),写成无条件的 `runner.os == 'Linux'` 就等于给一个没验证过的槽位加一条会红的判据——正是 M171 那个
|
||||
# 「豁免行要单独落」的同类事故。宁可这条只在有证据的槽位上真跑。
|
||||
- name: Linux x64 运行时冒烟(xvfb 真渲一帧 + 主题/后端日志断言)
|
||||
if: inputs.slot == 'linux-x64'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb xauth \
|
||||
libxkbcommon-x11-0 libx11-6 libxcursor1 libxrandr2 libxi6 libfontconfig1 fonts-dejavu-core
|
||||
out="$RUNNER_TEMP/linux-smoke"
|
||||
mkdir -p "$out/data"
|
||||
log="$out/launcher.log"
|
||||
# 退出码单独取:`tee` 之后的 `$?` 是 tee 的,管道里 cargo/launcher 的失败会被吞成 0。
|
||||
set +e
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
"$BUILD_DIR/unlucky-launcher-iced" --software-renderer \
|
||||
--screenshot "$out/linux-x64-first-frame.png" --data-dir "$out/data" 2>&1 | tee "$log"
|
||||
code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "launcher exit=$code"
|
||||
# 两条日志断言(两行都是**恒开**的,不依赖 ULL_ACCEPTANCE):
|
||||
# `[theme] source=…flavor=…follows_system=…` 证明主题是按预期来源定的(截图通道默认 Light),
|
||||
# `[backend] final=… result=started` 证明渲染后端真的起来了(而不是起来前就崩了)。
|
||||
grep -q '^\[theme\] source=' "$log" || { echo "::error::缺少 [theme] 行(主题来源没打印)"; exit 1; }
|
||||
grep -q '^\[backend\] final=.* result=started' "$log" || { echo "::error::缺少 [backend] final=… result=started(渲染后端没起来)"; exit 1; }
|
||||
python3 packaging/check_screenshot.py "$out/linux-x64-first-frame.png" --json
|
||||
ls -l "$out"
|
||||
exit "$code"
|
||||
|
||||
- name: 上传 Linux x64 首帧图(失败也传,人眼复核用)
|
||||
if: always() && inputs.slot == 'linux-x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x64-first-frame
|
||||
path: ${{ runner.temp }}/linux-smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ inputs.slot }}
|
||||
BUILD_DIR: ${{ inputs.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来(M140 起六个槽位都是 target/release),脚本据此定位产物并回读
|
||||
# 可执行文件头校验架构:arm64 槽位现在是**真跑** `ull --version`,「宿主≠产物就跳过」只留给本地交叉。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ inputs.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# (M142/M164)arm64 若红在别的步骤(cargo / 链接 / 打包 / 导入表门禁),也要有一条注解说清「红在哪」和
|
||||
# 「不挡发布」——只剩一句 `Process completed with exit code 1.` 的教训见上面那条体检步骤。
|
||||
# M164 起本槽位的红**只可能来自真实构建链路**(体检已降级为取证、永不 exit 1),所以这条注解同时也是
|
||||
# 「arm64 到底能不能编出来」的答案所在:红了就把上面的编译器/链接器原文当根因,别再回去改体检。
|
||||
# M184:Linux arm64 进来(它现在是 `mode: ci`,tag 推送默认就编),措辞按槽位分支 —— 两个 arm64 都**不在**发布名单里,
|
||||
# 但理由不同:windows-arm64 等「能不能出 PE」的答案,linux-arm64 等「真机跑不跑得起来」的答案。
|
||||
- name: arm64 失败收尾注解(红不挡发布)
|
||||
if: failure() && (inputs.slot == 'windows-arm64' || inputs.slot == 'linux-arm64')
|
||||
shell: bash
|
||||
env:
|
||||
SLOT: ${{ inputs.slot }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$SLOT" = "linux-arm64" ]; then
|
||||
reason="本槽位只有构建证据、运行时完全未验(没有 ARM64 真机跑过一帧)"
|
||||
else
|
||||
reason="失败步骤见本 job 的步骤名(M164 起只会是 cargo build / 链接 / 导入表门禁 / 打包,体检步不再判红)"
|
||||
fi
|
||||
echo "::error::[$SLOT] 本槽位已红:$reason。PUBLISH_SLOTS 当前是 windows-x64 + linux-x64 ⇒ 本次 Release 仍照发那两档,$SLOT 的产物不进 Release"
|
||||
@@ -0,0 +1,94 @@
|
||||
name: Release guard
|
||||
|
||||
# 发布标记语义守门(M163):**push tag 默认创建稳定版**这条不许只靠注释保证。
|
||||
#
|
||||
# 判据全部在 `packaging/check_release_semantics.py`,它做四件事:
|
||||
# ① 标记表:push tag(空串)的 create **不含** --prerelease;dispatch true ⇒ 预发布;
|
||||
# dispatch false ⇒ create 稳定、edit 显式摘标记;非法取值必须抛。
|
||||
# ② 真 bash 往返:把 `packaging/release_marker.py --emit-bash` 的产物 source 进 bash,
|
||||
# 读回 create_mark / edit_mark 两个数组——证明流水线**真跑的那段代码**对,不只是函数对。
|
||||
# ③ release-publish.yml 的可执行内容里没有 --prerelease 字面量:标记只能从那张表来。
|
||||
# ④ release.yml 的 dispatch 默认值 == false(读 YAML 结构,不是 grep 字符串)。
|
||||
# 最后一步 `--self-mutation` 是这条守门自己的变异自测:把「默认值改回 true」「塞回字面量」
|
||||
# 「表里 push 分支改成预发布」逐项注入临时副本,断言守门**每一项都红**、基线绿。守门自己
|
||||
# 抓不住回归时它会先红,所以这条断言不会变成装饰品。
|
||||
#
|
||||
# 为什么不并进 guards.yml:那份文件已经 398 行(400 行红线),再挂一个 job 必越线。
|
||||
# 改 `release.yml` 的 prerelease 输入 / 改那段 `gh release` 时,先跑
|
||||
# `python3 packaging/check_release_semantics.py`(本机可用 `python`,脚本会自己找 bash)。
|
||||
#
|
||||
# (M164)同一个 job 还挂**发布名单守卫** `packaging/check_publish_slots.py`:`PUBLISH_SLOTS` 与槽位表/产物
|
||||
# 必须对得上。PR 阶段跑源码级断言(名单非空、每项都是 `release.yml` 槽位表里的 `mode: ci` 档)——
|
||||
# 名单里写错一个字此前只会表现为「Release 侧一个也不匹配、齐全性检查红在别处」,根因要翻两层日志;
|
||||
# `--self-test` 再注入 11 条回归(缺资产 / 拼错 / 声明了 probe 档 / 名单声明了没产物 / 有产物没声明 /
|
||||
# `mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了必须拦住发布 / 提前把 `linux-arm64` 写进名单必须红)证明它会红。
|
||||
# 发布侧那道「产物 ⇄ 名单」的运行时断言在 `release-publish.yml`(同一份脚本,`--dist`)。
|
||||
#
|
||||
# (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/
|
||||
# app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被
|
||||
# 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物
|
||||
# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、
|
||||
# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml`
|
||||
# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为
|
||||
# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。
|
||||
# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准),
|
||||
# 该红的红、该绿的绿。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-semantics:
|
||||
name: 发布标记语义守门(push tag → 稳定版)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 标记表自检(push tag ⇒ 稳定版)
|
||||
run: python3 packaging/release_marker.py --self-test
|
||||
- name: 语义断言(标记表 / bash 往返 / 流水线只用表 / dispatch 默认值)
|
||||
run: python3 packaging/check_release_semantics.py
|
||||
- name: 变异自测(守门抓不抓得住回归)
|
||||
run: python3 packaging/check_release_semantics.py --self-mutation
|
||||
- name: 发布名单守卫自测(声明⇒必须有产物 / 有产物没声明⇒显式警告)
|
||||
run: python3 packaging/check_publish_slots.py --self-test
|
||||
- name: 发布名单与矩阵槽位一致(拼错字 / 声明了 probe 档 当场红)
|
||||
run: python3 packaging/check_publish_slots.py
|
||||
# M169:`update-test-source` 这条 feature 能让更新源指向任意本地目录,而更新链路的终点是
|
||||
# 替换用户盘上的 exe。它**默认关**、官方发布产物里不许有它,这条由脚本自动断言(不再靠注释):
|
||||
# 扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、`Makefile`/`justfile`/
|
||||
# `Taskfile` 与各 `Cargo.toml`;归属按 `jobs.*.steps[]` 的**列表项**判,落不到具名步骤的行一律红。
|
||||
# 唯一允许:本仓 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test update_e2e_test`)。
|
||||
- name: 测试通道守门自测(注入变异,该红的红、该绿的绿)
|
||||
run: python3 packaging/check_test_source_off.py --self-test
|
||||
- name: 发布产物不含测试通道(workflow / 打包脚本 / Cargo default 都不许开)
|
||||
run: python3 packaging/check_test_source_off.py
|
||||
# M190:签名脚本与 PE 版本资源门禁自己的自检。前者验 RFC 8032 向量(Ed25519 实现错了就发不出
|
||||
# 能被验过的签名),后者把「口径表 ⇄ pe_version.rs」「四段式版本号换算」对拍,并造一个没有版本
|
||||
# 资源的假 PE 断言判据**会红**(否则那道门禁对任何输入都绿,等于没有)。
|
||||
- name: 清单签名脚本自检(RFC 8032 向量 + 信任表标记解析)
|
||||
run: python3 packaging/sign_manifest.py --self-test
|
||||
- name: PE 版本资源判据自检(口径表对拍 + 假 PE 必红)
|
||||
run: python3 packaging/check_pe_version.py --self-test
|
||||
# M190 r1 复审 P1-1:`.github/workflows/release-build.yml` 的 sed 替换串里混进过一枚裸 0x01
|
||||
# (本意是 `\1`)。GitHub 解析这种文件会直接失败 ⇒ **整个 run 起不来**,而所有文本扫描型守卫
|
||||
# 都看不见它(它们把 workflow 读成文本)。这条用**真 YAML 解析器** parse 全部 workflow,
|
||||
# 外加一条「C0 控制字符」字节判据(两种失败模式各自独立报 `::error file=…,line=…`)。
|
||||
# PyYAML 在 GitHub runner 上自带;万一缺失,脚本自己装一次,仍失败则红(门禁不降级)。
|
||||
- name: workflow YAML 自检(注入 0x01 / 坏结构必须红)
|
||||
run: python3 packaging/check_workflows_yaml.py --self-test
|
||||
- name: workflow 全部可被真 YAML 解析器 parse(无裸控制字符)
|
||||
run: python3 packaging/check_workflows_yaml.py
|
||||
# M190 r3:`update-test-key` 是**信任锚的开关**(把公开的测试公钥并进信任表)。它只许由
|
||||
# `crates/presenter` 的 `[dev-dependencies]` 打开;进了发布构建就等于「谁都能签出官方更新」。
|
||||
# 判据:core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内、
|
||||
# 其余(workflow / packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红。
|
||||
- name: 测试公钥守门自检(注入 workflow / dependencies / 根 Cargo.toml 三处变异)
|
||||
run: python3 packaging/check_test_key_off.py --self-test
|
||||
- name: 测试公钥不进发布构建(只许 presenter 的 dev-dependencies)
|
||||
run: python3 packaging/check_test_key_off.py
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Release / publish(可复用)
|
||||
|
||||
# Release 流水线的**发布侧**(M145 拆分:`release.yml` 顶到 400 行红线)。本文件 = 拆分前 `release.yml` 的 `release`
|
||||
# job **逐字搬家**:裁剪白名单外的槽位产物、重算全量 `SHA256SUMS.txt`、渲染 `packaging/release-notes.md` 模板、
|
||||
# `gh release create/edit` 的同 tag 幂等,全部照旧。留在调用方 `release.yml` 的只有三件事:`needs: build`、
|
||||
# tag 门禁 `if: always() && !cancelled() && github.ref_type == 'tag'`、以及 `permissions: contents: write` 的授予。
|
||||
# **M164 新增一步**(其余仍照旧):下载 artifact 之后、裁剪之前的「发布名单守卫」`packaging/check_publish_slots.py`
|
||||
# —— 原来那份「名单内少一档就 fail」的内联循环搬进那个脚本,并补上反方向(有产物没声明 ⇒ `::warning::` 未发布)。
|
||||
# 与拆分前的三处等价改写(都只涉及"从哪里读",不涉及行为):
|
||||
# ① `secrets.GITHUB_TOKEN` → 本文件声明的入参 secret `PUBLISH_TOKEN`(调用方透传 `secrets.GITHUB_TOKEN`)。
|
||||
# 可复用工作流**拿不到**调用方未显式传入的 secret,写 `secrets.GITHUB_TOKEN` 会静默为空 → gh 401。
|
||||
# ② `inputs.prerelease`(原为 `workflow_dispatch` 的输入)→ `github.event.inputs.prerelease`(**字符串**形态的同一个
|
||||
# 值:`push` 触发时不存在 ⇒ 空串,dispatch 时是 `"true"`/`"false"`)。**三种取值各自创建什么**(M163)由
|
||||
# `packaging/release_marker.py` 一张表定义:空串 ⇒ 新建稳定版、edit 不碰标记;`true` ⇒ 新建成预发布;
|
||||
# `false` ⇒ 新建稳定版、edit 显式摘掉标记。本文件把那两个数组 `source` 出来用,自己**不写** case;
|
||||
# `packaging/check_release_semantics.py` 断言「本文件的可执行内容里没有 --prerelease 字面量」。
|
||||
# 为什么不用 `workflow_call` 入参转一道:dispatch 的 `inputs.prerelease` 是
|
||||
# **boolean**,而「空值 vs true/false」这条三态语义只有字符串能表达 —— 布尔入参无法取空,硬塞进字符串入参则是
|
||||
# 类型不符(`uses` 的 `with:` 会按被调方声明的类型校验;actionlint 当场报 `bool value cannot be assigned`)。
|
||||
# 取 event 上的字符串形态既保住三态,又不需要任何类型转换。
|
||||
# ③ `PUBLISH_SLOTS` / `runs-on` / `permissions` 的**内容**未动,只是被调方必须自己声明(被调方不继承调用方的
|
||||
# workflow 级 `env`;权限取调用方与本文件的交集 —— 两边都声明 `contents: write` 才拿得到写权)。
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PUBLISH_TOKEN:
|
||||
description: "调用方透传 `secrets.GITHUB_TOKEN`(本 job 已声明 contents: write,`gh release` 用它发版)"
|
||||
required: true
|
||||
# (M190 r1 复审 P1-2)**必须在这里声明**:可复用工作流只拿得到自己声明过的 secret,调用方
|
||||
# `release.yml` 按名传了这一个,若被调方不声明,它要么在调用处报错、要么静默求值为空串——
|
||||
# 两种结局都是「用户已把钥录进 Secret,发布仍在签名步红,而错误文案反过来指控 Secret 没配」。
|
||||
# `required: false` 是刻意的:缺省为空串,由签名步的 `::error::` + `exit 1` 做 fail-closed
|
||||
# (与 `release-build.yml` 的 `ULL_MSA_CLIENT_ID` 同一模式),失败信息才和事实一致。
|
||||
ULL_MANIFEST_SIGNING_KEY:
|
||||
description: "清单签名私钥(JSON:key_id + seed_hex);未配置时签名步 fail-closed、不出 Release"
|
||||
required: false
|
||||
|
||||
# 权限取交集:调用方 release.yml 的 release job 与本文件都声明 contents: write,交集才是 write(不声明则被收窄成 read)。
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
# ── 发布白名单:唯一决定「哪几个槽位进 Release」的开关(空格分隔,键同 matrix.slot)──
|
||||
# **M184 起是 `windows-x64` + `linux-x64`**:`linux-x64` 已真机验过 —— release 产物在 Linux 真机(WSLg 真窗口)
|
||||
# 渲出一帧、主题/后端日志齐全,并有一条同槽位的 xvfb 运行时门禁常驻在 `release-build.yml`(证据与实测输出见
|
||||
# `packaging/docs/linux-evidence.md`)。进名单的代价是「每轮发版都得真出这个包」:齐全性检查是「名单内少一档就 fail」,
|
||||
# 所以这条名单同时也是「linux-x64 构建红了就挡住整次发布」的闸 —— 与任务要求一致(声明为 ci 就得真能出包)。
|
||||
# **`linux-arm64` 不在名单里,且这一轮刻意不放**:它只有构建证据,运行时完全没有证据(没有 ARM64 真机跑过一帧,
|
||||
# 也没有 arm64 的 xvfb 冒烟)。解锁条件 = arm64 真机(或原生 runner)上真跑出一帧 + 主题日志 → 再把键加进这一行。
|
||||
# 只加进矩阵不加进名单时,它的产物会被下面的裁剪步删掉,但守卫会先打 `::warning::` 明说「未发布、会被裁掉」,
|
||||
# 不是静默丢弃(这条是任务里点名要验的反方向)。
|
||||
# `windows-arm64` 同理仍不在名单(M142/M164:它的五轮红全红在误判的体检步,判定权已交回编译器,等下一 tag 的 CI 证)。
|
||||
# **可逆,且只是一行**:真出包之后把键加进这一行(`"windows-x64 linux-x64 <新键>"`),齐全性检查与 SHA256SUMS
|
||||
# 自动跟着扩,再同步 README §1/§9 与 release-notes.md 两张表。
|
||||
# 判据不再靠注释保证:`packaging/check_publish_slots.py`(下面第二个 step 跑它;PR 阶段由 release-guard.yml
|
||||
# 跑同一份脚本)两个方向都查——声明了缺产物 ⇒ `::error::` + exit 1;有产物但没声明 ⇒ `::warning::` 明说未发布。
|
||||
# macOS 两档仍未验证,默认连构建都不跑(M139)。
|
||||
PUBLISH_SLOTS: "windows-x64 linux-x64"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# (M164)发布名单守卫:**声明了就必须有产物**(名单内某个槽位缺 GUI 或 CLI 就红,少一档不发版),
|
||||
# 反方向的「产物在、名单没声明」则打 `::warning::` 明说未发布 —— M142 之后那个方向是**静默**的(裁剪步只
|
||||
# echo 一行 `drop:`,没有注解、也没有非零退出),一个真编出来的 arm64 会被就地 `rm` 掉而没人知道。
|
||||
# 判据全在 `packaging/check_publish_slots.py`:同一份代码本地可跑,`--self-test` 注入 11 条回归断言该红的红。
|
||||
# 它跑在**裁剪前**的全量集上(裁剪只删名单外的文件),所以是原来「裁剪后再查」那次检查的严格超集。
|
||||
- name: 发布名单守卫(声明 ⇒ 必须有产物;有产物没声明 ⇒ 显式未发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
|
||||
|
||||
- name: 裁掉白名单外的槽位产物(构建取证 ≠ 发布)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 槽位键 `windows-x64` 落到文件名是 `windows_x64`(asset_stem),故取文件名里的「平台_架构」两段比白名单。
|
||||
keep=" "
|
||||
for slot in $PUBLISH_SLOTS; do keep+="${slot//-/_} "; done
|
||||
for f in dist/*; do
|
||||
base=${f##*/}
|
||||
[ "$base" = "SHA256SUMS.txt" ] && continue
|
||||
platform_arch=${base%.exe}; platform_arch=${platform_arch%.tar.gz}; platform_arch=${platform_arch#*_*_}
|
||||
case "$keep" in
|
||||
*" $platform_arch "*)
|
||||
echo "publish: $base"
|
||||
;;
|
||||
*)
|
||||
echo "drop: $base($platform_arch 不在发布白名单:macOS 两档未构建验证 / 两个 arm64 槽位待真机跑一帧;仍留在 dist-<slot> artifact 里取证)"
|
||||
rm -f "$f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
- name: 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 齐全性(名单内每个槽位 GUI 与 CLI 都要在)**已由上上步的「发布名单守卫」断言**,且那一步看的是裁剪前的
|
||||
# 全量集 —— 同一套规则只留一份实现,免得两处判据漂开。这里只负责重算摘要:各槽位各写一份 SHA256SUMS.txt,
|
||||
# 合并后只有一份是真的,故在 Release 侧按已发布的资产重算。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher_* ull_* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成更新清单 manifest.json(D50:固定名挂 Release 资产)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
RELEASE_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 先自验再产文件:golden 对拍 + 「DTO 严格解析」镜像(未知字段 / 摘要长度 / size=0 / 版本重复
|
||||
# 这些在发布步当场炸,而不是变成用户机器上的「查不到更新」)。零第三方依赖,毫秒级。
|
||||
python3 packaging/make_manifest.py --self-test
|
||||
# 事实来源是上一步**重算**出来的 dist/SHA256SUMS.txt:清单不另算一份摘要,两者不可能不一致。
|
||||
# url 钉死 tag(不是 latest):清单里每条都指向与它自报版本相符的字节。
|
||||
# min_supported / notes 刻意先不写:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级),
|
||||
# 后者在 Release 正文里已有,两处各写一份迟早漂。
|
||||
python3 packaging/make_manifest.py \
|
||||
--dist dist \
|
||||
--version "$TAG" \
|
||||
--channel stable \
|
||||
--asset-base-url "$RELEASE_BASE/$TAG" \
|
||||
--released-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
# (M190)清单签名:**签不上就不发版**。私钥只从仓库 Secret `ULL_MANIFEST_SIGNING_KEY` 进环境变量,
|
||||
# 本步 `set +x` 且只回显**公钥**(日志里永远不出现私钥)。四条命令任一非零 ⇒ 本步红 ⇒ 不出 Release:
|
||||
# ① `--self-test`:RFC 8032 向量(签名脚本自己没坏)
|
||||
# ② `--check-pinned-keyring`:Secret 里的钥**必须**已在 `crates/core/src/update/trust.rs` 钉住
|
||||
# (签出一份客户端不认的清单,比不发版更糟)
|
||||
# ③ `--sign`:签的就是上一步落盘的 `dist/manifest.json` 原始字节,产出 `dist/manifest.json.sig`
|
||||
# 红了的处置见 `packaging/docs/signing.md` §4 那张表(缺什么、怎么补,都写在脚本的 stderr 里)。
|
||||
- name: 签更新清单(Ed25519;签不上就阻断发布)
|
||||
shell: bash
|
||||
env:
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set +x
|
||||
PY=$(command -v python3 || command -v python)
|
||||
if [ -z "$PY" ]; then echo "::error::找不到 python,签名步骤无法运行(签名门禁不降级)"; exit 1; fi
|
||||
"$PY" packaging/sign_manifest.py --self-test
|
||||
if [ -z "${ULL_MANIFEST_SIGNING_KEY:-}" ]; then
|
||||
echo "::error::仓库 Secret ULL_MANIFEST_SIGNING_KEY 没配(或为空):没有签名私钥就不发版。"
|
||||
echo "::error::补法见 packaging/docs/signing.md §3:python3 packaging/sign_manifest.py --generate-key,把打印的 JSON 贴进 Secret、把 PinnedKey 那行贴进 crates/core/src/update/trust.rs"
|
||||
exit 1
|
||||
fi
|
||||
"$PY" packaging/sign_manifest.py --check-pinned-keyring
|
||||
"$PY" packaging/sign_manifest.py --sign dist/manifest.json
|
||||
# ④ 产出后**用 trust.rs 里钉住的公钥回验一遍**:复现「客户端会怎么判」,而不是信签名脚本
|
||||
# 自己那句「签好了」。私钥不需要参与,所以这一步跑在同一个环境里也不多一个泄露面。
|
||||
"$PY" packaging/sign_manifest.py --verify-pinned dist/manifest.json
|
||||
ls -l dist/manifest.json dist/manifest.json.sig
|
||||
|
||||
- name: 生成 Release 说明(支持矩阵模板)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
version = os.environ["TAG"].lstrip("v")
|
||||
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
|
||||
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
|
||||
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
|
||||
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
|
||||
print(body[:400])
|
||||
PY
|
||||
|
||||
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
PRERELEASE: ${{ github.event.inputs.prerelease }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||
# ── 预发布标记:判据只在 packaging/release_marker.py(M163),本文件不写 case ──
|
||||
# 三种触发各自创建什么(那张表是唯一真相源):
|
||||
# push tag(PRERELEASE 为空) → create 不带标记(**稳定版**)、edit 不碰标记
|
||||
# dispatch prerelease=true → create / edit 都带预发布标记
|
||||
# dispatch prerelease=false → create 不带标记、edit 显式摘掉预发布标记
|
||||
# 空值必须是稳定版:GitHub 的 releases/latest 只指向**非 prerelease** 的最新 Release,而
|
||||
# 自更新清单固定取 `releases/latest/download/manifest.json`(D50)—— push tag 打成预发布,
|
||||
# 更新通道就是 404(v0.2.2 至今如此,M163 修的就是这条;v0.2.2 那个 Release 保留为历史预发布)。
|
||||
# 先落文件再 source,不用 `source <(...)`:非法的 prerelease 取值要让这一步当场红,
|
||||
# 进程替换里的失败会被 `set -e` 吞掉。
|
||||
marks=/tmp/release-marks.sh
|
||||
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
|
||||
source "$marks"
|
||||
echo "create_mark=(${create_mark[*]:-}) edit_mark=(${edit_mark[*]:-})"
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,故三份与产物并列挂 Release(条款随包);`ull` 是独立资产。
|
||||
# manifest.json 是更新清单(D50):固定文件名,客户端问 `releases/latest/download/manifest.json`。
|
||||
# `manifest.json.sig` 必须与清单一起上传:客户端取清单后取它旁边的签名(`GitHubReleaseSource`
|
||||
# 拼的地址是 `<产物根>/manifest.json.sig`),漏传等于让所有人都验签失败(M190)。
|
||||
assets=(dist/unlucky-launcher_* dist/ull_* dist/manifest.json dist/manifest.json.sig dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁/重编):Release 已存在 → 覆盖资产 + 刷新说明,再 `create` 会因已存在而失败。
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
|
||||
else
|
||||
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
|
||||
fi
|
||||
+121
-321
@@ -1,17 +1,33 @@
|
||||
name: Release
|
||||
|
||||
# 发布流水线:tag `v*` → 四平台(六槽位)矩阵构建 → 单文件产物 + SHA256SUMS
|
||||
# → 上传 artifact + 创建 GitHub Release(pre-release 默认开)。同一 tag 重跑幂等:
|
||||
# Release 已存在时覆盖资产与说明,不再 `gh release create`(见 release job 末步);
|
||||
# 重跑**不碰** pre-release 标记,该标记只在 `inputs.prerelease` 显式给出时才被断言。
|
||||
# 发布流水线:tag `v*` → **默认构建四个槽位**(原生 `windows-latest` / `windows-11-arm` / `ubuntu-22.04` /
|
||||
# `ubuntu-22.04-arm`)→ 单文件产物 + SHA256SUMS → artifact + GitHub Release。**push tag 默认创建稳定版**(M163:不带
|
||||
# `--prerelease`,否则 `releases/latest` 永远 404、自更新通道取不到清单);同 tag 重跑幂等(覆盖资产与说明、不 create、
|
||||
# 不碰标记)。
|
||||
# **构建 ≠ 发布**:只有 `PUBLISH_SLOTS` 里的槽位才进 Release 与 SHA256SUMS ——**M184 起是 `windows-x64` + `linux-x64`**。
|
||||
# `linux-x64` 本轮翻牌的依据在 `packaging/docs/linux-evidence.md`:x86_64 槽位在 `ubuntu-22.04`(glibc 2.35,与本地
|
||||
# WSLg 验证同一地板)真编出 release 产物、真开窗渲出一帧,`release-build.yml` 里还有一条同槽位的 xvfb 运行时门禁
|
||||
# ——「声明为 ci 就必须真能跑出帧」,不是「矩阵里写了就算支持」。
|
||||
# **`linux-arm64` 本轮只构建、不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟)。
|
||||
# 解锁条件写在 `packaging/release-notes.md` 与 `packaging/docs/compliance-support-boundaries.md` §9 ——真机跑一帧之后
|
||||
# 才把键加进 `PUBLISH_SLOTS`,不拿没跑过的包去发(M184 tower 裁决)。
|
||||
# `windows-arm64` 仍留矩阵里取证、红了不挡 x64 发布(M142/M164:它那五轮红全红在一个误判的编译前体检步,该门禁已拆,
|
||||
# 判定权交回编译器)。`macos-*` 两档从未验证,默认连构建都不跑(M139:省配额、不留红叉);整表六槽位取证走
|
||||
# `gh workflow run release.yml -f all_slots=true`(槽位表在 `plan`)。
|
||||
# 名单 ⇄ 产物两个方向都有守卫(M164):`packaging/check_publish_slots.py`(PR 阶段在 release-guard.yml,发布侧在
|
||||
# release-publish.yml 的 `--dist` 模式)——声明了缺产物 ⇒ 红;有产物没声明 ⇒ `::warning::` 明说未发布。
|
||||
# 契约唯一真相源 packaging/README.md,打包本体 packaging/make_artifacts.py(本地与 CI 同一份脚本)。`inputs.prerelease`
|
||||
# 只在 **tag ref** 上 dispatch 才可达(跑 tag 得 `gh workflow run release.yml --ref v0.2.1`);分支 ref 的 dispatch 与
|
||||
# push 一样被 release job 的 `if: github.ref_type == 'tag'` 挡在发布之外。
|
||||
#
|
||||
# 契约唯一真相源:packaging/README.md(命名/目录/运行时依赖)。
|
||||
# 打包本体在 packaging/make_artifacts.py(本地与 CI 同一份脚本,逐字节同布局)。
|
||||
# 手动触发(workflow_dispatch)默认只跑构建不建 Release —— 用来验证矩阵仍然编得过。
|
||||
# 注意 `inputs.prerelease` 只在 **tag ref** 上 dispatch 才可达:Actions 页面的
|
||||
# "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.1.0`。
|
||||
# 分支 ref 的 dispatch 与 push 事件一样被 release job 的 `if: github.ref_type == 'tag'`
|
||||
# 挡在发布之外,所以「dispatch 只构建不发布」与「dispatch 可关 pre-release」并不矛盾。
|
||||
# M145 拆分(本文件与 packaging/README.md 都已顶到 400 行红线,下一处改动必越线):本文件只留**触发 + 槽位表 + 编排**,
|
||||
# 两个 job 的实体各搬一份可复用工作流(`workflow_call`),步骤逐字未改:
|
||||
# - `.github/workflows/release-build.yml` = 原 `build` job 全流程(Linux 依赖 / arm64 工具链取证(M164 起只取证)/
|
||||
# cargo build / 静态 CRT 导入表门禁 / 打包 / 归档自检 / 上传 artifact / arm64 收尾注解),矩阵键换成同名 `inputs.*`。
|
||||
# - `.github/workflows/release-publish.yml` = 原 `release` job 全流程(`PUBLISH_SLOTS` 白名单 / 裁剪 / 重算
|
||||
# SHA256SUMS / 渲染说明 / `gh release` 幂等),`secrets.GITHUB_TOKEN` 改为显式透传的 secret。
|
||||
# (M164 在裁剪前插了一步「发布名单守卫」:主线 7 步,本分支 8 步。)
|
||||
# `on` / `env` / `permissions` / `concurrency` 与 `plan` 的槽位表**一字未动**。
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -19,10 +35,18 @@ on:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prerelease:
|
||||
description: "标记为 pre-release(仅在 tag ref 上 dispatch 时才生效)"
|
||||
all_slots:
|
||||
description: "构建全部六个槽位(含未验证的 macOS 两档);默认只跑四个(Windows 两档 + Linux 两档)"
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
prerelease:
|
||||
# 默认 false(M163):这是「新建 / 改写 Release 打不打预发布」的唯一开关,默认值必须落在稳定版上——
|
||||
# Actions 页面点一次 "Run workflow" 就是这条默认值在决定新版本进不进稳定通道。
|
||||
# 三种触发各自创建什么由 packaging/release_marker.py 一张表定义,守门在
|
||||
# packaging/check_release_semantics.py(`.github/workflows/release-guard.yml` 跑它)。
|
||||
description: "标记为 pre-release(默认 false = 稳定版;仅在 tag ref 上 dispatch 时才生效)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -36,320 +60,96 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# M139:矩阵不写死在 `build` 里,改由本 job 按 `inputs.all_slots` 生成 —— 矩阵不能带 `if`,而 `jobs.<id>.strategy`
|
||||
# 可读 `needs`,故用 `fromJSON(needs.plan.outputs.matrix)`。**槽位表只在下面这一处**,改槽位只改这里。
|
||||
plan:
|
||||
name: 选槽位(默认四个:Windows 两档 + Linux 两档)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
steps:
|
||||
- name: 生成 build 矩阵(`all_slots=true` 才整表六槽位)
|
||||
id: pick
|
||||
shell: bash
|
||||
env:
|
||||
ALL_SLOTS: ${{ inputs.all_slots }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - "$ALL_SLOTS" >> "$GITHUB_OUTPUT" <<'PY'
|
||||
import json, sys
|
||||
# 槽位表(唯一真相源)。mode:"ci" = 默认在 CI 里构建;"probe" = 未验证,仅 all_slots=true 时构建。
|
||||
# **没有交叉槽位**(M140):每槽在自己的架构上原生构建,故无 `target` 键、产物一律在 target/release。
|
||||
# 要加回交叉槽位:`target` 键 + `build_dir=target/<triple>/release` + `rustup target add` 与 cargo 的 `--target`。
|
||||
SLOTS = [
|
||||
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,双击即用的单文件。
|
||||
dict(slot="windows-x64", os="windows-latest", build_dir="target/release", rustflags="-C target-feature=+crt-static", mode="ci"),
|
||||
# Windows arm64:**原生** `windows-11-arm`(4C/16G,公开仓库免费)。该标签自 2026-09 起是
|
||||
# **Windows 11 Arm64 + Visual Studio 2026** 镜像(VS 在 `C:\Program Files\Microsoft Visual Studio\18`),
|
||||
# 镜像清单里 `VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都装好了。M140 前是在 windows-latest 上交叉;
|
||||
# v0.2.1~v0.2.3 的红全红在 M141/M142 那个**编译前体检**步(cargo 永远 skipped),M164 已把它降级为只取证。
|
||||
# 原生还让 `ull --version` 冒烟真跑,dumpbin 走 Hostarm64 分支。
|
||||
dict(slot="windows-arm64", os="windows-11-arm", build_dir="target/release",
|
||||
rustflags="-C target-feature=+crt-static", mode="ci"),
|
||||
# ↓ Linux 两档(M184):**都改为 `mode: ci`** —— tag 推送默认就编,不再只在 `all_slots=true` 的取证跑里出现。
|
||||
# Linux 固定 22.04 而非 latest:否则 glibc 地板跟着 runner 漂移(22.04 = glibc 2.35,与本地 WSLg 验证同一地板)。
|
||||
# 两档的差别只在「有没有运行时证据」,而这件事由 `PUBLISH_SLOTS`(发布侧那一行)决定,不在这里:
|
||||
# `linux-x64` 已真机验过(release 产物在 WSLg 真窗口渲出一帧 + 同槽位的 xvfb 门禁,证据见
|
||||
# `packaging/docs/linux-evidence.md`)⇒ 进名单;`linux-arm64` 只有构建、**运行时完全未验**(没有 arm64 真机
|
||||
# 跑过一帧)⇒ 不进名单,产物照出 artifact 供取证,靠 `check_publish_slots.py` 打 `::warning::` 明说未发布。
|
||||
# **别把 arm64 直接加进名单**:解锁条件是真机跑一帧,不是「构建绿了」。
|
||||
dict(slot="linux-x64", os="ubuntu-22.04", build_dir="target/release", rustflags="", mode="ci"),
|
||||
dict(slot="linux-arm64", os="ubuntu-22.04-arm", build_dir="target/release", rustflags="", mode="ci"),
|
||||
# ↓ 两档 macOS **未验证,不纳入 CI**:从未真构建成功、也没在真机跑过,默认一个都不编;`all_slots=true` 时照编
|
||||
# (将来验证 macOS 的唯一入口,别删)。真机验过后:mode 改 "ci" + 键加进 PUBLISH_SLOTS +
|
||||
# 同步 README §1/§9 与 release-notes.md。
|
||||
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||
dict(slot="macos-arm64", os="macos-latest", build_dir="target/release", rustflags="", mode="probe"),
|
||||
dict(slot="macos-x64", os="macos-15-intel", build_dir="target/release", rustflags="", mode="probe"),
|
||||
]
|
||||
keep = [s for s in SLOTS if s["mode"] == "ci" or sys.argv[1] == "true"]
|
||||
print("matrix=" + json.dumps({"include": keep}, separators=(",", ":")))
|
||||
print("本次构建槽位:" + " ".join(s["slot"] for s in keep), file=sys.stderr)
|
||||
PY
|
||||
|
||||
# 构建实体在 release-build.yml(`workflow_call`)。矩阵仍由上面 `plan` 生成、**在外层展开**:`strategy.matrix`
|
||||
# 必须在调用侧求值(被调方只按单个槽位的四个入参跑),所以 `matrix.*` 只出现在这个 job 的 `with:` 里。
|
||||
build:
|
||||
name: build (${{ matrix.slot }})
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Windows x64:MSVC + 静态 CRT(+crt-static)→ exe 不依赖 VC++ 可再发行包,
|
||||
# 双击即用的单文件形态;UCRT 本身就是 Win10+ 的系统组件。
|
||||
# 产物根 target/release;自检与打包都从 matrix.build_dir 取路径(不硬编码)。
|
||||
- slot: windows-x64
|
||||
os: windows-latest
|
||||
build_dir: target/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Windows arm64:在 windows-latest(x64 宿主)上**交叉编译**到 aarch64-pc-windows-msvc。
|
||||
# 为什么不用 windows-11-arm 原生 runner(GitHub 文档 2026 已把 `windows-11-arm` 与
|
||||
# `windows-11-vs2026-arm` 列为公开仓库**免费**的标准 arm64 runner,原生路线可行):
|
||||
# ① 双 Windows 槽位共用同一套已验证镜像/工具链,tag 重跑行为与既有槽位一致;
|
||||
# ② M50 的 dumpbin 定位链取的是 `Hostx64/x64/dumpbin.exe`,dumpbin 读 ARM64 PE 的
|
||||
# 导入表没问题,而原生 ARM 宿主上那条链得再长一条 `Hostarm64/arm64` 分支——为一个
|
||||
# 槽位放宽刚立的门禁不值得;
|
||||
# ③ 交叉编译产物路径变成 target/<triple>/release,已用 matrix.build_dir 参数化。
|
||||
# 若将来改成原生:只需把 os 换成 windows-11-arm、删掉 target/rustflags 依赖并给 dumpbin
|
||||
# 定位链补 Hostarm64 分支(届时再评估,不在这里预先放宽)。
|
||||
- slot: windows-arm64
|
||||
os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
build_dir: target/aarch64-pc-windows-msvc/release
|
||||
rustflags: -C target-feature=+crt-static
|
||||
# Linux:固定 ubuntu-22.04(glibc 2.35 地板)而不是 latest,
|
||||
# 否则产物的 glibc 底线会跟着 runner 漂移(24.04 的 glibc 明显更高)。
|
||||
- slot: linux-x64
|
||||
os: ubuntu-22.04
|
||||
build_dir: target/release
|
||||
# Linux arm64:公开仓库免费的原生 ARM64 runner(原生构建,不做交叉)。
|
||||
# 用 `ubuntu-22.04-arm` 而**不是** 24.04:与 x64 同 glibc 地板(2.35),
|
||||
# 覆盖 Debian 12 / Ubuntu 22.04 arm64 / Raspberry Pi OS Bookworm(2.36)这类实际在跑的
|
||||
# ARM 发行版;换 24.04 会把地板抬高、把上面前两个挡在门外。该家族标签有
|
||||
# `ubuntu-22.04-arm` / `ubuntu-24.04-arm` / `ubuntu-26.04-arm`,均 4C/16GB、
|
||||
# 公开仓库免费不限量(GitHub 文档 Standard runners for public repositories)。
|
||||
- slot: linux-arm64
|
||||
os: ubuntu-22.04-arm
|
||||
build_dir: target/release
|
||||
# macOS:latest = arm64;Intel x64 用 macos-15-intel(macos-13 已下线)。
|
||||
- slot: macos-arm64
|
||||
os: macos-latest
|
||||
build_dir: target/release
|
||||
- slot: macos-x64
|
||||
os: macos-15-intel
|
||||
build_dir: target/release
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Linux 图形栈构建依赖(winit / femtovg 编译期需要)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \
|
||||
libwayland-dev libxcb1-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# 只有交叉编译槽位需要额外 target(原生槽位留空 → 本步整体跳过)。
|
||||
# 不把 `${{ }}` 直接拼进 bash:与后面的打包步骤同一纪律,一律经 env 传入再展开。
|
||||
- name: 追加交叉编译目标 ${{ matrix.target }}
|
||||
if: matrix.target != ''
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: rustup target add "$TARGET"
|
||||
|
||||
# 交叉链接的前提是镜像里真有 ARM64 的 MSVC 库(VC `/lib/arm64/libcmt.lib`,+crt-static
|
||||
# 就靠它)与 Windows SDK 的 ARM64 导入库(`um/arm64/kernel32.lib`)。缺了照样是失败,
|
||||
# 但先给一句能照做的话,而不是让链接器吐一屏乱码才发现方向:门禁不降级,只把失败说清楚。
|
||||
- name: Windows ARM64 交叉工具链体检
|
||||
if: matrix.target == 'aarch64-pc-windows-msvc'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
roots=("C:/Program Files/Microsoft Visual Studio" "C:/Program Files (x86)/Microsoft Visual Studio")
|
||||
vc_lib=$(find "${roots[@]}" -maxdepth 11 -type f -path '*/VC/Tools/MSVC/*/lib/arm64/libcmt.lib' 2>/dev/null | head -n1 || true)
|
||||
sdk_version=$(ls -d "C:/Program Files (x86)/Windows Kits/10/Lib"/* 2>/dev/null | tail -n1 || true)
|
||||
sdk_lib=""
|
||||
if [ -n "$sdk_version" ]; then
|
||||
sdk_lib=$(find "$sdk_version/um/arm64" -maxdepth 1 -name 'kernel32.lib' 2>/dev/null | head -n1 || true)
|
||||
fi
|
||||
echo "MSVC ARM64 静态 CRT: ${vc_lib:-<缺失>}"
|
||||
echo "SDK ARM64 导入库: ${sdk_lib:-<缺失>}"
|
||||
if [ -z "$vc_lib" ] || [ -z "$sdk_lib" ]; then
|
||||
echo "::error::交叉链接缺 ARM64 库:给 VS 加上 Microsoft.VisualStudio.Component.VC.Tools.ARM64(含 ARM64 Windows SDK),或把该槽位改用原生 windows-11-arm runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: cargo build --release --locked
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target_args=()
|
||||
if [ -n "$TARGET" ]; then
|
||||
target_args+=(--target "$TARGET")
|
||||
fi
|
||||
cargo build --release --locked "${target_args[@]}" --bin unlucky-launcher --bin ull
|
||||
|
||||
- name: Windows 单文件自检(导入表不得出现 VC++ 可再发行 DLL)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
# 跨槽位的关键点:产物在 target/<triple>/release 而不是 target/release。
|
||||
EXE: ${{ matrix.build_dir }}/unlucky-launcher.exe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
|
||||
# 可再发行包,那就不是"单文件"了,直接 fail。
|
||||
#
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
|
||||
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
|
||||
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
|
||||
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
|
||||
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
|
||||
# Hostx64 的 dumpbin 读 ARM64 PE 的导入表没问题,所以 arm64 槽位共用同一条链。
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
|
||||
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
|
||||
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=${hit//\\//}
|
||||
VIA="vswhere"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
|
||||
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
[ -d "$root" ] || continue
|
||||
hit=$(find "$root" -maxdepth 11 -type f \
|
||||
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="find: $root"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="PATH"
|
||||
fi
|
||||
fi
|
||||
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
|
||||
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
|
||||
exit 1
|
||||
fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
ls -l "$EXE"
|
||||
"$DUMPBIN" -nologo -dependents "$EXE" | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 打包(产物 + SHA256SUMS)
|
||||
shell: bash
|
||||
env:
|
||||
# 不把 `${{ }}` 直接拼进 bash 脚本:git ref 名允许 `$`/反引号/`"`,一律经 env 传入再展开。
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SLOT: ${{ matrix.slot }}
|
||||
BUILD_DIR: ${{ matrix.build_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# git-bash 与 Linux/macOS runner 的 python 入口名不一致,先探一遍。
|
||||
PY=$(command -v python3 || command -v python)
|
||||
# 槽位与产物根都从矩阵来:交叉槽位的二进制不在 target/release,脚本据此定位
|
||||
# 并回读可执行文件头校验架构(宿主 x64 ≠ 产物 arm64 时冒烟自动跳过,只等 CI 真机)。
|
||||
args=(--slot "$SLOT" --build-dir "$BUILD_DIR")
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
args+=(--version "$TAG") # 与 Cargo.toml 版本交叉校验
|
||||
fi
|
||||
"$PY" packaging/make_artifacts.py "${args[@]}" --out dist
|
||||
ls -l dist
|
||||
|
||||
- name: 归档内容自检(二进制定位 + 合规文本)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for archive in dist/*.tar.gz; do
|
||||
[ -e "$archive" ] || continue
|
||||
listing=$(tar -tzf "$archive")
|
||||
for required in unlucky-launcher LICENSE NOTICE README.md; do
|
||||
if ! grep -q "/$required$" <<<"$listing"; then
|
||||
echo "::error file=$archive::$archive 缺少 $required"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "OK: $archive 含二进制与 LICENSE/NOTICE/README.md"
|
||||
done
|
||||
if ls dist/*.exe >/dev/null 2>&1; then
|
||||
echo "Windows 单 exe 形态:LICENSE/NOTICE/README.md 作为 Release 资产并列分发"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.slot }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
# 可复用工作流的权限只取「调用方 ∩ 被调方」,且不继承调用方的 `env`:两边都声明 read(与原 build job 一致)。
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
slot: ${{ matrix.slot }}
|
||||
os: ${{ matrix.os }}
|
||||
build_dir: ${{ matrix.build_dir }}
|
||||
rustflags: ${{ matrix.rustflags }}
|
||||
secrets:
|
||||
# (M149)微软登录的公共客户端 id:值来自仓库 Secret,被调方把它烘进发布二进制(`option_env!` 注入)。
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret,故必须按名传(与下面 PUBLISH_TOKEN 同一模式,
|
||||
# 不用 `inherit`:那会把仓库全部 secret 一起透进被调方)。Secret 未录入时求值为空串,被调方把空白
|
||||
# 当「没配」——产物只是不带 id,不会拿空 id 去联网。
|
||||
ULL_MSA_CLIENT_ID: ${{ secrets.ULL_MSA_CLIENT_ID }}
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: build
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
# `always()` + `!cancelled()`:白名单外的槽位(`windows-arm64`、`linux-arm64`,以及只在 `all_slots` 取证跑里
|
||||
# 存在的 macOS 两档)红了**不能连坐**那两档已发布的 —— 真正拦发布的是被调方「发布白名单齐全性」那一步:
|
||||
# 白名单里少任何一个才 fail(M184 之后 `linux-x64` 也在白名单里,它红了就该拦)。
|
||||
# tag 门禁留在这里(不在被调方):分支 ref 的 dispatch 连发布 job 都不该起。
|
||||
if: always() && !cancelled() && github.ref_type == 'tag'
|
||||
# `contents: write` 必须由**调用方**授予:可复用工作流只能收窄权限、不能提权(交集语义)。
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: 矩阵完整性 + 重算全量 SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -l dist
|
||||
# 六槽位全齐才发版:缺任意一个就 fail(宁可不出 Release,也不出少一档的 Release)。
|
||||
for slot in windows-x64 windows-arm64 linux-x64 linux-arm64 macos-arm64 macos-x64; do
|
||||
if ! compgen -G "dist/*-$slot*" >/dev/null; then
|
||||
echo "::error::矩阵缺产物:$slot"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# 各平台任务各写一份 SHA256SUMS.txt,合并后只有一份是真的 —— 在 Release 侧重算。
|
||||
cd dist
|
||||
rm -f SHA256SUMS.txt
|
||||
sha256sum unlucky-launcher-* > SHA256SUMS.txt
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: 生成 Release 说明(支持矩阵模板)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, pathlib
|
||||
version = os.environ["TAG"].lstrip("v")
|
||||
template = pathlib.Path("packaging/release-notes.md").read_text(encoding="utf-8")
|
||||
sums = pathlib.Path("dist/SHA256SUMS.txt").read_text(encoding="utf-8").strip()
|
||||
body = template.replace("{{VERSION}}", version).replace("{{SUMS}}", sums)
|
||||
pathlib.Path("release-body.md").write_text(body, encoding="utf-8", newline="\n")
|
||||
print(body[:400])
|
||||
PY
|
||||
|
||||
- name: 创建或更新 GitHub Release(同 tag 重跑幂等)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_flags=(--title "Unlucky Launcher $TAG" --notes-file release-body.md)
|
||||
# pre-release 标记只在**显式要求**时才断言:
|
||||
# PRERELEASE=true / false —— 来自 tag ref 上的 workflow_dispatch 输入;
|
||||
# PRERELEASE 空 —— push 触发的运行或它的 Re-run,此时**不碰**该标记,
|
||||
# 否则会把用户手动提升为 stable 的 Release 静默翻回 pre-release。
|
||||
# 首次创建(create)仍保持「pre-release 默认开」的老契约:空值按 true 处理。
|
||||
create_mark=()
|
||||
edit_mark=()
|
||||
case "${PRERELEASE:-}" in
|
||||
true)
|
||||
create_mark=(--prerelease)
|
||||
edit_mark=(--prerelease)
|
||||
;;
|
||||
false)
|
||||
edit_mark=(--prerelease=false)
|
||||
;;
|
||||
"")
|
||||
create_mark=(--prerelease)
|
||||
;;
|
||||
*)
|
||||
echo "::error::非预期的 inputs.prerelease 取值:$PRERELEASE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Windows 单 exe 装不下 LICENSE/NOTICE/README.md,这三份与产物并列挂到 Release 上,
|
||||
# 保证每个下载平台的许可条款都随包分发。
|
||||
assets=(dist/unlucky-launcher-* dist/SHA256SUMS.txt LICENSE NOTICE README.md)
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
# 同 tag 重跑(补丁 / 重编):Release 已存在 → 覆盖资产 + 刷新说明,
|
||||
# 不再 `create`(那会因已存在而失败,把整条流水线在最后一步染红)。
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release edit "$TAG" "${base_flags[@]}" "${edit_mark[@]}"
|
||||
else
|
||||
gh release create "$TAG" "${assets[@]}" "${base_flags[@]}" "${create_mark[@]}"
|
||||
fi
|
||||
uses: ./.github/workflows/release-publish.yml
|
||||
secrets:
|
||||
# 可复用工作流拿不到调用方未显式传入的 secret;`gh release` 用的就是这一枚安装令牌。
|
||||
PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (M190)清单签名私钥:被调方 release-publish.yml 用它签 `manifest.json`。没配时是空串,
|
||||
# 签名步会带着「缺什么、怎么补」的注解当场红(宁可不出 Release,也不出未签名的 Release)。
|
||||
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
|
||||
@@ -18,6 +18,10 @@ dist/
|
||||
out/
|
||||
target/
|
||||
|
||||
# Python bytecode (packaging/*.py 本地跑过就会生成;提交它会让每次 spawn 都打 WIP 快照)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
Generated
+1084
-1998
File diff suppressed because it is too large.
Load diff
+79
-2
@@ -3,13 +3,34 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/core",
|
||||
"crates/agent",
|
||||
"crates/app",
|
||||
# M85(D26):UI 框架无关的宿主层。Slint 版(crates/app)与 iced 版(crates/app-iced)
|
||||
# 共用它;Slint 退役(M118 起 `crates/app` 已不是 workspace 成员)后只剩 iced 依赖,本 crate 不受影响。
|
||||
"crates/presenter",
|
||||
"crates/cli",
|
||||
"crates/design",
|
||||
# P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。
|
||||
# bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||
# M118 起它是发布矩阵里**唯一**的 GUI 来源:发布了 Slint 版的那个 bin 名属于被排除的 `crates/app`。
|
||||
"crates/app-iced",
|
||||
# M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方,
|
||||
# 使 `crates/design` 能继续宣称「零第三方依赖」。
|
||||
"crates/palette-extract",
|
||||
# M92(D28):国际化地基——语言探测 / 中英消息目录 / 中文字体策略。零第三方依赖、零 UI 依赖,
|
||||
# 壳层(iced 版现在,别的壳将来)与 presenter 都依赖它,所以它不能认任何一个渲染框架。
|
||||
"crates/i18n",
|
||||
]
|
||||
# M118(D26):`crates/app` 是 Slint 退役树 —— UI 已迁到 iced,目录与源码**原样保留**作历史
|
||||
# 参考(另一份参考资产见 DevDocs/ops/slint-reference/),但不再进默认构建与发布矩阵。摘出去的
|
||||
# 是整棵 slint 1.18 + usvg 0.48 依赖树:本机单路冷编 ≈14 分钟,而 CI 的 `--workspace` 每次都会
|
||||
# 编它,既拖长反馈,又会在并行超配时随机 OOM(见 DevDocs/00-踩坑记录-p2.md T7)。
|
||||
# 排除 ≠ 删除,但**它确实不再可编**:该 manifest 整份都是 `*.workspace = true` 继承写法,
|
||||
# 一旦不是 workspace 成员,继承无源、manifest 解析即失败(实测:
|
||||
# `cargo metadata --manifest-path crates/app/Cargo.toml` → "error inheriting `edition` …")。
|
||||
# 要读这一版实现请直接读源码,别再尝试构建它。
|
||||
exclude = ["crates/app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.3"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["AskaEth <i@yeij.top>"]
|
||||
@@ -50,11 +71,22 @@ md-5 = "0.10"
|
||||
# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
||||
|
||||
# M190(core::update::ed25519):清单签名**验签**用官方实现,不再自写曲线算术。
|
||||
# M190 r1 复审的裁定:信任锚不许用自写密码学(非规范编码/小阶点/批量验证差异的后果是
|
||||
# 「验签通过但本不该通过」)。BSD-3-Clause,在 deny.toml 白名单内。
|
||||
# 只开 `std`(不用 `rand_core`/`batch`/`fast` 这类可选面):本仓的签名侧在
|
||||
# `packaging/sign_manifest.py`,二进制里只有「验」这一半。
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
|
||||
|
||||
# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`,
|
||||
# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# M87(D40):配色取色的解码适配层。版本与 `crates/app-iced` 的截图通道对齐(同一份
|
||||
# `image` 只编译一次);编解码器由各 crate 自己按需开 feature,这里只钉版本。
|
||||
image = { version = "0.25", default-features = false }
|
||||
|
||||
# UI 层唯一入口(D2:Slint,RF 2.0 轨道)。
|
||||
# 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底,
|
||||
# Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。
|
||||
@@ -92,3 +124,48 @@ lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
# P6(M96):日常迭代 / 本机验收用的「快」档,发布仍走上面的 release。
|
||||
# `inherits = "release"` 让其余各项(strip / debug / overflow-checks …)照抄 release,
|
||||
# 只覆盖拖慢构建的四处:
|
||||
# - lto = "thin":跨 crate 内联仍有,但可以并行;fat LTO 在 codegen-units = 1 下等于
|
||||
# 全程序串行链接(375 个 crate 排队),一次性吃掉十几分钟。
|
||||
# - codegen-units = 16:给 codegen 留并行度,不再把整个 crate 挤成一个 CGU。
|
||||
# - opt-level = 2:介于 "z" 与 3 之间,"z" 会为了体积关掉一批循环/内联优化,代价是运行期。
|
||||
# - panic = "unwind":release 是 panic = "abort",inherits 会照抄。留着它是为了让 fast 档编出来的
|
||||
# **二进制**走 unwind(panic 会栈展开,不是直接 abort)。
|
||||
# ⚠️ 理由不是「不写就跑不了测试」:`cargo test` **不**依赖这一行 —— cargo 对 test 目标自己强制
|
||||
# unwind。实测 `cargo test --profile release -p unlucky-i18n`(release = abort 档)照跑,23 个
|
||||
# 测试全过,且 test 单元的 rustc 参数里**没有** `-Cpanic=`。别拿「cargo test 能跑」当这行生效的证据;
|
||||
# 要看就用 `cargo build --profile {fast,release} -v` 比 rustc 参数:release 有 `panic=abort`,fast 没有。
|
||||
#
|
||||
# ⚠️ 定位(实测,别误读):本机(12 核 + 机械盘源码盘 + -j 4)冷编译 fast = 24m27s,
|
||||
# **比 release 的 16m01s 还慢**,体积是 2.3 倍(36.41 vs 15.72 MiB GUI)。瓶颈在两个大
|
||||
# UI crate 的编译 + 链接 + 磁盘 IO,LTO/CGU 这几个旋钮被掩盖了。它的价值在 **SSD 机器**和
|
||||
# **增量重编**(thin 不必每次全程序 LTO);「加了 fast 档」≠「冷编译会变快」。
|
||||
[profile.fast]
|
||||
inherits = "release"
|
||||
opt-level = 2
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
panic = "unwind"
|
||||
|
||||
# P6(M96):依赖不需要为体积做极致优化,但**实测结论与直觉相反**——把依赖的 opt-level 从
|
||||
# release 的 "z" 抬到 3(本档最初就是这么定的)会让冷编译**更慢**、体积还更大:
|
||||
# 同机冷编译 opt-3 = 22m45s / 19.46 MiB(GUI),opt-"z" = 16m01s / 15.72 MiB(两臂同为 cu16)。
|
||||
# 所以这里**opt-level 保持 "z",只把并行度放到 16**;workspace 自己的 crate 仍吃 "z" / 单 CGU。
|
||||
# ⚠️ **未测**:cu1 → cu16 的收益本轮没有同条件对照(两臂都是 cu16,唯一自变量是 opt-level),
|
||||
# 所以「cu16 才是收益来源」不是实测结论,要归因得单独跑一轮 cu1 对照。另:GUI 体积相对旧基线
|
||||
# 涨了 12.9%(13.92 → 15.72 MiB),其中「代码增长」与「依赖 cu16」也没分离。
|
||||
# **只调优化强度**:panic 与 strip 绝不写进这里。那两项改变的是整个二进制的运行时行为
|
||||
# (panic 策略决定能否 unwind 出栈、strip 决定符号表留不留),不是「给依赖放宽」的范畴。
|
||||
[profile.release.package."*"]
|
||||
opt-level = "z"
|
||||
codegen-units = 16
|
||||
|
||||
# `inherits = "release"` 会把 `[profile.release.package."*"]` **一起继承过来**(实测:删掉下面这块,
|
||||
# fast 档的外部依赖就从 opt-level 2 退回 `"z"`;workspace 成员不受 `*` 覆盖、仍是 fast 自己的 2)。
|
||||
# 所以这块**必须存在**,它是承重墙不是冗余兜底 —— 删掉会让 fast 档的依赖静默退回 `"z"`。
|
||||
[profile.fast.package."*"]
|
||||
opt-level = 2
|
||||
codegen-units = 16
|
||||
@@ -7,24 +7,60 @@ Copyright 2026 AskaEth (YeiJ)
|
||||
第三方组件声明
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
1. Slint(https://slint.dev)
|
||||
1. Slint(https://slint.dev)——**历史源码,不随任何产物分发**
|
||||
|
||||
本产品的 GUI 层使用 Slint。Slint 以 Royalty-Free 2.0 许可(LicenseRef-Slint-Royalty-free-2.0)
|
||||
使用,未修改 Slint 本体源码。
|
||||
本产品**当前发布的 GUI 框架是 iced**(见第 3 节),二进制里不含任何 Slint 代码。
|
||||
仓库里仍保留 Slint 版 GUI 的源码(`crates/app`),仅作历史参考:它已被 cargo workspace
|
||||
`exclude`,不参与构建、测试与发布,也不会进入本项目的任何 Release 资产。
|
||||
|
||||
Slint Royalty-Free 2.0 的唯一义务:应用「关于」页显示官方 AboutSlint 部件,
|
||||
或在公开下载页放置「Made with Slint」徽章。本项目的落地位置见 crates/app 的关于页(P2 排期)
|
||||
与 GitHub Release 说明页;在此之前由本文件的声明承担告知义务。
|
||||
因此 Slint Royalty-Free 2.0(LicenseRef-Slint-Royalty-free-2.0)**对当前产物不适用**;它适用的是
|
||||
此前已发布、且确实链接了 Slint 的 `v0.1.0` 构建——义务挂在每一次「随应用分发」上,而不是挂在某个
|
||||
版本号上。上游许可 §2 给出两种形式:(a) 在可从顶层菜单进入的「关于」页/对话框里显示官方
|
||||
`AboutSlint` 部件(没有该页时改为启动画面);(b) 在可下载该二进制的公开网页上显示官方归属徽章。
|
||||
**`v0.1.0` 的设置页「关于」分组里内置了 `AboutSlint` 部件**(`crates/app/ui/pages/settings.slint`,
|
||||
从侧栏进入);该部件是否满足 §2(a) 的全部条件、以及 `v0.1.0` 发布页上那是文字链接而不是 §2(b) 点名的
|
||||
官方徽章图这两点,**本文件不作法律判断**,具体以 Slint 官方许可原文为准。`v0.1.0` 的 NOTICE、发布页
|
||||
说明与本文件这一节**都只是记录,不构成履行声明**。后续版本不再链接 Slint,该义务不随新产物新增;
|
||||
本文件保留这一节,是为了不让这段历史丢失,而不是声明本产品依赖 Slint。
|
||||
|
||||
Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。
|
||||
若将来重新把 `crates/app` 纳入构建并发布链接 Slint 的二进制,该义务随之恢复——届时需同时恢复
|
||||
AboutSlint 部件/徽章与本节的现行声明。Slint 的其余许可分支(GPL-3.0-only / Slint Software
|
||||
License 3.0)本项目始终不适用。
|
||||
|
||||
2. Rust crates 生态
|
||||
2. Google Material Color Utilities(https://github.com/material-foundation/material-color-utilities)
|
||||
|
||||
本产品的色彩令牌(crates/design 的 color 模块)是 Material 3 色彩算法(HCT 色彩空间 +
|
||||
ColorSpec2021 调色板/角色映射)的 **Rust 重写移植**,依据 Apache License 2.0 使用,未把其任何语言的源码作为依赖
|
||||
引入;除下面注明的那张生成常数表外,也没有复制其代码文件。
|
||||
|
||||
实际移植的部分(均为纯算法,逐函数对照后以 Rust 重写):
|
||||
|
||||
- `hct/Cam16.java`、`hct/ViewingConditions.java`、`hct/Hct.java` → `crates/design/src/color/cam16.rs`、
|
||||
`viewing_conditions.rs`、`hct.rs`(CAM16 色相/彩度 + CIELAB `L*` 亮度)
|
||||
- `hct/HctSolver.java` → `crates/design/src/color/hct_solver.rs` + `critical_planes.rs`
|
||||
(色域映射求解器;其中 255 项 `CRITICAL_PLANES` 常数表是原样搬运的生成数据)
|
||||
- `utils/ColorUtils.java`、`utils/MathUtils.java` → `crates/design/src/color/space.rs`
|
||||
- `palettes/TonalPalette.java`、`dynamiccolor/ColorSpec2021.java`、`dynamiccolor/DynamicScheme.java`
|
||||
→ `crates/design/src/color/tonal.rs`、`palettes.rs`、`scheme.rs`(Tonal Spot 变体的彩度常数与
|
||||
49 个角色的 tone 映射)
|
||||
|
||||
对齐验证:`crates/design/tests/official_baseline.rs` 与 `tests/official_accent.rs` 的期望色值,
|
||||
是把上述官方 Java 实现(Apache-2.0)在本机编译运行后输出的结果抄本——合计 290 项逐通道
|
||||
比对(baseline 49 角色 × 2 模式 = 98 项;强调色 8 个种子 × 12 个强调族角色 × 2 模式 = 192 项),
|
||||
误差上限 1(实测为 0)。两文件的表头各写着自己的分项口径。
|
||||
|
||||
Apache License 2.0 全文见 https://www.apache.org/licenses/LICENSE-2.0
|
||||
上游版权声明:Copyright Google LLC。
|
||||
|
||||
3. Rust crates 生态
|
||||
|
||||
当前 GUI 框架:iced 0.14(MIT),连同 winit(Apache-2.0)、wgpu(MIT OR Apache-2.0)、
|
||||
tiny-skia(BSD-3-Clause)——它们都在下面的白名单内。
|
||||
cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC /
|
||||
CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。
|
||||
GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。
|
||||
|
||||
3. 参考实现致谢
|
||||
4. 参考实现致谢
|
||||
|
||||
本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2
|
||||
(https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。
|
||||
@@ -2,59 +2,418 @@
|
||||
|
||||
**English** · [简体中文](README.zh-CN.md)
|
||||
|
||||
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
|
||||
> **Official project home**: <https://github.com/AskaEth/Unlucky-Launcher> | downloads, issues and
|
||||
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
>
|
||||
> **Version in this README: `0.2.3`** — `unlucky-launcher --version` prints the same number.
|
||||
|
||||
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
|
||||
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
|
||||
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
|
||||
Minimum baseline: Windows 10.
|
||||
Unlucky Launcher is a free, open-source **Minecraft: Java Edition** launcher written in Rust
|
||||
(Apache-2.0). It ships as **one native binary per platform** — no installer, no bundled runtime, no
|
||||
WebView — and covers the launcher job from installing a version to reading a crash report: instances
|
||||
and versions, mod loaders and modpacks, mods / resource packs / shaders, per-instance Java, Microsoft
|
||||
/ third-party / offline accounts, and a crash diagnosis that runs locally on a YAML rule engine.
|
||||
Nothing of yours goes through a service of ours: account tokens stay on the machine.
|
||||
|
||||
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
|
||||
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
|
||||
without a usable GPU still get the full UI)
|
||||
- License: Apache-2.0 © AskaEth (YeiJ)
|
||||

|
||||
|
||||
## Compliance statement
|
||||
*The instances page — 1180×760, English UI, light theme. The instance names are test data, not
|
||||
anybody's real setup. That `Launch failed · exit0 静默失败` line is also an honest sight of a known
|
||||
gap: the reason text is still raw Chinese straight out of the launch layer (see
|
||||
[Not done yet](#not-done-yet)).*
|
||||
|
||||
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
|
||||
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
|
||||
account and launch the game they already own. Account tokens are stored only on the user's local
|
||||
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
|
||||
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
|
||||
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
|
||||
join online-mode servers. Not affiliated with Mojang or Microsoft.
|
||||
## What works today
|
||||
|
||||
## Status
|
||||
### Instances, versions and launching
|
||||
|
||||
The core engine is complete (workspace: 983+ tests passing):
|
||||
- **Instances** — a list-detail page: the local instance list on the left; on the right the selected
|
||||
instance's version, loader, isolation mode (an isolated game directory or the shared one),
|
||||
files-on-disk state and last-played time, with Play / Stop. Narrow windows switch panes instead of
|
||||
stacking them.
|
||||
- **Where an instance comes from** — installing a version (with a name, loader and loader version)
|
||||
or importing a modpack. The instances page's own **New instance / Import / Duplicate / Open folder
|
||||
/ Delete** buttons are **not wired yet**: they are placeholder actions that only write a line to
|
||||
stderr (`crates/app-iced/src/app/update/page_messages.rs`), and the instance wizard is the pending
|
||||
piece (`crates/app-iced/src/pages/mod.rs`, deviation #8).
|
||||
- **Version management** — a three-column rail (version type / major version / search) over the
|
||||
Mojang version list: release, snapshot and ancient versions, plus loader selection and a loader
|
||||
version when you install. Cancelling an installation really stops it — the in-flight file is cut, the
|
||||
job ends at the next stage boundary, and no instance is registered
|
||||
(`crates/presenter/src/host/dispatch.rs:61-88`). The CLI can plan the same install without
|
||||
downloading (`ull install --dry-run`: per-role file counts, total size, and one source-chain sample
|
||||
per file category; the full file list is in `--json`).
|
||||
- **Launching** — a 10-stage command-line assembly (`crates/core/src/launch/`), per-instance JVM and
|
||||
game arguments, a native-library pass, and a JSONL session record for every run. Process trees are
|
||||
killed as trees, not as one PID.
|
||||
- **Per-instance launch arguments** — JVM arguments, game arguments, memory, the Java executable and
|
||||
the graphics backend (`default` / `opengl` / `vulkan`) live in the instance file under `launch.*`,
|
||||
and every field falls back on its own: instance → global setting → factory default, so an instance
|
||||
that only sets memory does not wipe the global JVM arguments. JVM arguments are *appended* (global
|
||||
first, instance last, so the JVM's last-one-wins rule makes the instance win). An out-of-range
|
||||
memory value counts as "not set" instead of producing `-Xmx0m`, an empty field means "follow", and
|
||||
the backend row only appears for game versions that have it. The CLI and the GUI read the same
|
||||
resolved values (`crates/core/src/game/launch_overrides.rs`).
|
||||
- **Crash diagnosis is real and local** — `crates/core/src/diagnose` collects five kinds of signals
|
||||
(exit code, `latest.log`, `crash-reports`, `hs_err_pid` files, environment snapshot including the
|
||||
mod list), matches them against a YAML rule set, and shows the findings with fix actions on the
|
||||
logs page. No model, no network: it works offline and on a machine with no GPU.
|
||||
|
||||
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with
|
||||
entitlement verification
|
||||
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
|
||||
to the AI features
|
||||
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
|
||||
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
|
||||
- Multi-source download engine; YAML rule-based crash diagnosis
|
||||
- Usable as an MCP server for external agent tooling (`ull agent serve`)
|
||||
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
|
||||
authentication and cannot join servers with online-mode enabled
|
||||
### Mod loaders, packs and the content library
|
||||
|
||||
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`.
|
||||
- **Loaders** — Fabric, Quilt, NeoForge and Forge. Forge/NeoForge versions that need the official
|
||||
installer `processors` really run them, with a managed JDK if the machine has none.
|
||||
- **Modpacks** — import a CurseForge manifest, a Modrinth `.mrpack`, or a MultiMC/Prism instance;
|
||||
export as `.mrpack` or a CurseForge manifest. Cancelling an **import** is interface-level only: the
|
||||
panel closes and the result is discarded, but the import thread runs to the end, because `core`'s
|
||||
import request has no cancel token yet (`crates/presenter/src/host/addon/pack.rs:28-42`).
|
||||
- **Import conflicts are decided per file** — before anything is written, the scan lists every
|
||||
conflicting file (what it is, why it conflicts, how big it is) in pages of 40, and three entries
|
||||
decide the batch: keep all, overwrite all, or choose per item — unpicked rows stay on "keep". Files
|
||||
that do not conflict are never rewritten, and the report still names every conflict afterwards.
|
||||
- **Content library** — modpacks / mods / resource packs / shaders with keyword, source, version,
|
||||
loader and sort filters, paged results, and one-click *install into this instance*.
|
||||
- **Online source: Modrinth, anonymously** — search goes through `/v2/search` with facets taken from
|
||||
the target instance (project type, game version, loader); project details and version lists follow,
|
||||
and a remote entry whose hash is known is completed through `/v2/version_file/<hash>`. No API key,
|
||||
and no POST: the download engine has no request-body field, so hashes are looked up one GET at a
|
||||
time. Being offline is its own state — "not connected" — never "no matching content".
|
||||
- **What that page will refuse, and says so on the row** — a modpack cannot be installed *from* the
|
||||
library (packs go through the import pipeline above); CurseForge's *install* is disabled because
|
||||
its file endpoint is not wired; and an item whose game version or loader does not match the target
|
||||
instance is disabled with the mismatch spelled out. Nothing fails silently.
|
||||
- **CurseForge search needs a key** — set `ULL_CF_API_KEY` before launching; without it that source
|
||||
is greyed out in the source filter. The key stays in the environment: it is not written to the
|
||||
settings file and not logged (`crates/presenter/src/host/addon.rs`).
|
||||
|
||||
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and
|
||||
the full interface ship with subsequent versions — use the CLI until then.
|
||||
### Java
|
||||
|
||||
## Module map
|
||||
A managed JDK per instance, with a four-vendor fallback chain ordered by region — Temurin first, then
|
||||
Oracle before Microsoft inside mainland China (measured 28× faster there) and Microsoft before Oracle
|
||||
elsewhere, Zulu last — overridable through the `java.vendorPriority` setting
|
||||
(`crates/core/src/game/java/vendor.rs`). Mirrors are region-aware too. `java list` shows every JDK it
|
||||
can actually see (including the ones the official launcher downloaded), `java install <major>` fetches
|
||||
one. The game's JDK is the launcher's data, not its own dependency.
|
||||
|
||||
### Accounts
|
||||
|
||||
Three tracks on one page:
|
||||
|
||||
- **Microsoft** — OAuth 2.0 device code flow → Xbox Live → XSTS → `api.minecraftservices.com`.
|
||||
- **Third-party community auth servers** (authlib-injector style).
|
||||
- **Local offline profiles**, for isolated testing.
|
||||
|
||||
**Microsoft sign-in works out of the box in official builds.** Release binaries carry the Azure public
|
||||
client id they need (a public client id, not a credential — it ships inside the binary), so there is
|
||||
nothing to configure before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` still overrides it: the
|
||||
variable is read at startup, so restart the launcher after changing it. Building from source bakes in
|
||||
whatever that variable holds at build time (`ULL_MSA_CLIENT_ID=<id> cargo build`). A build with no id
|
||||
anywhere stops before any network call, and its alert says exactly what is missing: the title
|
||||
"Microsoft sign-in is not configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with
|
||||
it, then restart), and `Code: msa_not_configured`. It offers no Retry button, because retrying in that
|
||||
same process cannot help. Every other sign-in failure keeps the older shape: one category line plus
|
||||
its own `Code:` line, with the full reason only in the launcher log. Community auth servers and local
|
||||
profiles need no id.
|
||||
|
||||
### Look and language
|
||||
|
||||
- **One UI stack** — iced 0.14 + Material 3 Expressive, a self-drawn title bar, an animated
|
||||
navigation rail, light / dark / follow-system.
|
||||
- **Appearance** — window background image with a colour pipeline (panel colours can be derived from
|
||||
the wallpaper), per-panel transparency, and whole-window transparency on Windows. On Windows 11
|
||||
and Server 2025 the system is asked for rounded corners; macOS and Linux leave corners to the
|
||||
desktop's own rules; nothing is drawn by hand.
|
||||
- **CPU fallback** — `--software-renderer` keeps the interface rendering on machines with no usable
|
||||
GPU or broken drivers, and the backend probe falls back on its own when no adapter is usable. On
|
||||
that path panel-level translucency and blur are switched off (window-level transparency still
|
||||
works) and the settings panel says so.
|
||||
- **Localisation** — system-language detection, an explicit override in settings, and persistence
|
||||
across restarts. Most copy comes from one message catalogue (`crates/i18n`).
|
||||
**Boundary, stated because it is easy to over-read that sentence**: the anti-hard-coding guard
|
||||
(`crates/app-iced/src/pages/instances/tests/i18n.rs`) scans the instances page sources only, not
|
||||
every screen; a hard-coded *Chinese* literal has no ASCII word for it to match, so nothing catches
|
||||
that class; and the appearance panel's older controls are still hard-coded English
|
||||
(`crates/app-iced/src/pages/appearance/section.rs`), so a Chinese UI still shows them in English.
|
||||
|
||||
### The assistant, and the MCP server
|
||||
|
||||
These are two different things and the README should not blur them:
|
||||
|
||||
- **The in-app assistant is a placeholder shell today.** Its transport is scripted
|
||||
(`crates/app-iced/src/presenter/transport.rs`), no real model is connected, and the UI says so in
|
||||
four places on every screen it appears — subtitle, input placeholder, empty-state body, and a
|
||||
"Simulated" chip. The session state, unread count, draft, confirm flow and named failure codes are
|
||||
real presenter state; only the model behind them is not there yet.
|
||||
- **The MCP server is real.** `ull agent serve` exposes the launcher core to external agent tools as
|
||||
an MCP server over stdio or Streamable HTTP, loopback only (`127.0.0.1`, default port 47823),
|
||||
read-only unless `agent.mcp.allow_write=true`. The same ten tool specs (`crates/agent/src/tools/`)
|
||||
feed both shapes, and both go through the same executor — schema validation, sensitivity tiers,
|
||||
audit trail. `ull agent config` prints ready-made Claude Code / Cursor snippets.
|
||||
|
||||
### Command line
|
||||
|
||||
The GUI binary also answers every CLI subcommand **before any UI code runs** — no window, no renderer
|
||||
probe, no font scan. A second, lighter `ull` artifact serves the same command set from a binary that
|
||||
links no graphics stack at all.
|
||||
|
||||
```bash
|
||||
unlucky-launcher # GUI
|
||||
unlucky-launcher --software-renderer # GUI, CPU renderer
|
||||
unlucky-launcher instances list # CLI, no window
|
||||
unlucky-launcher --json launch 1.20.1 # CLI, global options before the command
|
||||
unlucky-launcher --version # CLI, version + OS summary
|
||||
```
|
||||
|
||||
The dispatch rule is deliberately narrow, so nothing that used to work changed meaning:
|
||||
|
||||
- The first argument is a bare word (not `-…`) → it sits in the subcommand position, so the CLI gets
|
||||
it. GUI flags never appear there: `--screenshot` / `--diff` / `--data-dir` take their values
|
||||
*after* the flag.
|
||||
- The first argument is a flag → the line is a CLI call only if `crates/cli`'s parser also produces a
|
||||
real command (`--help`, `--version`, `--json instances list`). `--data-dir X` alone parses to "no
|
||||
subcommand", so it still opens the GUI; every GUI-only flag (`--software-renderer`,
|
||||
`--screenshot*`, `--renderer`, `--theme`, `--diff`, `--force-state`, …) fails CLI parsing, so it
|
||||
still opens the GUI.
|
||||
|
||||
**Unknown subcommands are an error, not a GUI launch**: `unlucky-launcher lst` prints "未知命令
|
||||
`lst`", hints `unlucky-launcher --help`, and exits 1. Silently opening a window would tell a script
|
||||
that its typo took effect. The subcommand table lives in exactly one place
|
||||
(`crates/cli/src/cli/parse.rs`), so both binaries accept the same set by construction. `ull --help`
|
||||
lists them all: `instances`, `install`, `launch`, `pack import`, `accounts`, `sessions`, `logs`,
|
||||
`diag`, `config`, `java`, `update`, `agent`, `version`, `help`.
|
||||
|
||||
### Updates, and the stable channel
|
||||
|
||||
- **Self-update is real, and GUI and CLI share one core.** The settings panel's Update group and
|
||||
`ull update check` / `ull update apply` both build the same `core::update::Job`
|
||||
(`crates/core/src/update/`): fetch the manifest at `releases/latest/download/manifest.json`, compare
|
||||
versions, admit the channel, verify length + SHA256, then swap the binary atomically — rename the
|
||||
old file aside, move the new one in, roll back if that fails. A running Windows exe can be
|
||||
*renamed* but not overwritten, which is why the swap is a rename.
|
||||
- **Four outcomes, never conflated**: up to date / update available / a newer version exists but ships
|
||||
no artifact for this platform / the check itself failed (offline, broken manifest, channel
|
||||
mismatch). A failed check is never reported as "up to date".
|
||||
- **Only the stable channel is published.** `update.channel` defaults to `stable`; beta / alpha /
|
||||
nightly are modelled and reserved. A manifest is admitted only when both its declared channel and
|
||||
the version's own pre-release stage agree — a manifest can lie, a version number cannot.
|
||||
- **Why a release has to be marked stable**: `releases/latest/download/*` only points at the newest
|
||||
**non-prerelease** release, and the manifest address is pinned there. `0.2.3` is the first release
|
||||
created without the pre-release flag (`0.2.2` stays a historical pre-release). Which trigger creates
|
||||
which marker — push tag ⇒ stable, dispatch `prerelease=true` ⇒ pre-release — is one table
|
||||
(`packaging/release_marker.py`), with a guard over it (`packaging/check_release_semantics.py`).
|
||||
- **Nothing installs itself behind your back.** The check at startup can be turned off
|
||||
(`update.auto_check`, the same key the CLI reads), and installing is a button or an explicit
|
||||
`ull update apply`. Debug builds can rehearse the whole chain against a local directory source
|
||||
(`ULL_UPDATE_DIR`); release builds do not contain that path at all.
|
||||
|
||||
### Where the logs are
|
||||
|
||||
Everything the launcher writes on its own side lives under `<data root>/logs/`. The settings page has
|
||||
a **Log folder** row that shows that path with *Open folder* and *Copy path*; the logs page header has
|
||||
*Open folder* as well. The data root is `<exe folder>/.ULL` in portable mode, `<per-user app data>/ULL`
|
||||
in standard mode, and `<custom root>/.ULL` in custom mode — the same value the settings page displays
|
||||
as the launcher data directory.
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `logs/launcher.log` | The launcher's own runtime log: boot probe, font/theme decisions, backend choice, **the named reason when a render backend fails to start**, and every launch attempt (command line facts, spawn result, exit). Rolled over at 1 MiB into `logs/launcher.1.log`, exactly **one** old copy kept, so it cannot grow without bound. |
|
||||
| `logs/launcher.1.log` | The previous 1 MiB, replaced on the next rollover. |
|
||||
| `logs/game/<session-id>.jsonl` | One JSONL event stream per launch: the command line (secrets redacted), every stdout/stderr line, exit code. |
|
||||
| `logs/game/index.jsonl` | The one-line-per-session index the logs page lists. |
|
||||
| `logs/install.log` | Install pipeline journal, one line per stage. |
|
||||
| `logs/session-log-location.json` | A record of where session logs live and where they used to live. Rewritten on every start. |
|
||||
| `data/agent/sessions/game/…` | **Read-only compatibility**: sessions written by older builds. They are still listed and still openable; nothing is moved and nothing is deleted. |
|
||||
|
||||
The game's own files are not in there — they sit in the instance's run directory: `logs/latest.log`,
|
||||
`crash-reports/crash-*.txt`, and `hs_err_pid*.log`. **`crash-reports/` only exists when the JVM lived long
|
||||
enough to write one.** If a mod, a missing library, or a wrong Java kills the game before Minecraft
|
||||
initialises, the game never gets that far, and the honest evidence is the session JSONL plus
|
||||
`launcher.log` — not a crash report. That is how the game behaves, not something the launcher is
|
||||
hiding.
|
||||
|
||||
### Not done yet
|
||||
|
||||
This list is the reason to believe the rest. None of it is hidden behind marketing copy.
|
||||
|
||||
- **No installer.** You download a file and run it — there is no setup step, and updates replace that
|
||||
file in place instead of installing anything (see [Updates](#updates-and-the-stable-channel)).
|
||||
- **The update path has only been exercised against a local directory source.** The whole chain
|
||||
(manifest → download → SHA256 → rename/rollback) ran with a debug build and a fixture, because the
|
||||
real `releases/latest/download/manifest.json` was unreadable while `0.2.2` was still a pre-release.
|
||||
The first observation on the live stable channel happens after `0.2.3` ships.
|
||||
- **Several buttons are placeholders.** On the instances page: *New instance*, *Import*, *Duplicate*,
|
||||
*Open folder*, *Delete*. In the settings frame: *Save*, *Reset
|
||||
section*, *Add override*. Each writes `[page] 占位动作 …` to stderr and changes nothing — instance
|
||||
management from the GUI is the instance-wizard work still outstanding, and settings change as you
|
||||
edit them rather than on a Save.
|
||||
- **Linux x64 is published, and "published" means "it runs", not "it looks finished".** The release workflow
|
||||
builds `linux-x64` on a native Ubuntu 22.04 runner by default and ships it, because the release artifact itself
|
||||
was started under a real display server (WSLg), opened a window, rendered a non-blank frame and printed its
|
||||
theme/backend lines — and that same check is now a per-slot CI gate on every release
|
||||
(`packaging/docs/linux-evidence.md` has the raw output). Nobody has yet looked at it on a real desktop
|
||||
distribution: dragging, resizing, font metrics and CJK shaping on a real desktop session are unverified.
|
||||
Two things from the Windows list are simply not code paths on Linux, so there is nothing to verify: whole-window
|
||||
transparency needs a native window handle the app does not have there (`hwnd=none`), and rounded corners are
|
||||
delegated to the desktop compositor (`corner=policy=system`) instead of being drawn by the app.
|
||||
`linux-arm64` is built (native `ubuntu-22.04-arm`) but **not** published — no ARM64 machine has ever run it, so
|
||||
the artifact stays as build evidence only.
|
||||
- **macOS is not built by the release workflow, and it is not finalised.** It has two rounds of real-device
|
||||
evidence (Apple Silicon), which is why the build exists at all — but the menu-bar name, the placement of the
|
||||
three window buttons (macOS puts them top-left; ours are drawn top-right), and 300s+ steady state still need a
|
||||
real machine. macOS builds are unsigned, so the first launch needs the Gatekeeper quarantine attribute cleared
|
||||
(`xattr -d com.apple.quarantine`). The macOS runtime smoke job is a manual `workflow_dispatch` probe with
|
||||
`continue-on-error`, not a gate; `gh workflow run release.yml -f all_slots=true` builds the two slots for
|
||||
evidence, but they are never published.
|
||||
- **The content library is cramped at low window heights**, and the window height is not wired into
|
||||
the page model yet — the page lays itself out for a nominal height rather than the one it gets.
|
||||
- **Launch-side failure text is not structured.** The tile takes a Chinese sentence straight from the
|
||||
launch layer (`crates/core/src/launch/exit_analysis.rs`) instead of a reason code the UI could
|
||||
translate — which is exactly the `静默失败` you can see in the English screenshot above.
|
||||
- **The in-app assistant has no model behind it** (see above). The MCP server does.
|
||||
- **Install progress is stage-level**, not byte-level, on the content-library install path.
|
||||
- **No native file chooser.** Import takes drag-and-drop or a pasted path.
|
||||
- **Android/Termux is experimental and not promised**: no released asset targets it. A GUI build is
|
||||
possible from source via the glibc toolchain (`packaging/docs/termux-glibc-build.md`, branch notes:
|
||||
[`README.termux.md`](README.termux.md)); it was verified on-device twice: once on CPU software rendering, once with GPU acceleration via a self-built Mesa/turnip stack (see the evidence in README.termux.md).
|
||||
|
||||
## Download and run
|
||||
|
||||
Grab the file for your platform from
|
||||
[Releases](https://github.com/AskaEth/Unlucky-Launcher/releases). Names follow
|
||||
`unlucky-launcher_<version>_<platform>_<arch>`; the single source of truth for that shape is
|
||||
`packaging/make_artifacts.py::asset_stem()`:
|
||||
|
||||
| Slot | Asset | Shape |
|
||||
|------|-------|-------|
|
||||
| `windows-x64` | `unlucky-launcher_<version>_windows_x64.exe` | bare `.exe`, double-click to run |
|
||||
| `linux-x64` | `unlucky-launcher_<version>_linux_x64.tar.gz` | one archive, one top-level directory; glibc 2.35+ and the X11/Wayland + fontconfig stack |
|
||||
| `windows-arm64` | `unlucky-launcher_<version>_windows_arm64.exe` | bare `.exe` — **built, not published**, see below |
|
||||
| `linux-arm64` | `unlucky-launcher_<version>_linux_arm64.tar.gz` | same — **built, not published** (no runtime evidence yet) |
|
||||
| `macos-arm64` | `unlucky-launcher_<version>_macos_arm64.tar.gz` | same — **not built** by the release workflow |
|
||||
| `macos-x64` | `unlucky-launcher_<version>_macos_x64.tar.gz` | same — **not built** by the release workflow |
|
||||
| every published slot | `ull_<version>_<platform>_<arch>[.exe]` | bare CLI binary, its own asset |
|
||||
| all | `SHA256SUMS.txt` | `sha256sum -c` eats it directly |
|
||||
|
||||
**This release publishes `windows-x64` and `linux-x64`, and nothing else.** The publish job keeps only the slots
|
||||
named in `PUBLISH_SLOTS` (`.github/workflows/release-publish.yml`) and deletes every other artifact before it
|
||||
writes `SHA256SUMS.txt`, so a row marked **not published** is a naming rule, not a downloadable asset. A guard
|
||||
(`packaging/check_publish_slots.py`) watches all three relations: a slot named in the list whose artifacts are
|
||||
missing turns the release red (that is also what makes a red `linux-x64` build block the release), artifacts that
|
||||
exist for an unlisted slot get an explicit "not published" warning instead of being deleted quietly, and a
|
||||
`mode: ci` slot that produced nothing at all warns too — a red slot must never be visible only as a missing row.
|
||||
|
||||
Of the unlisted rows, CI builds two. `windows-arm64` is built natively on GitHub's `windows-11-arm` runner
|
||||
(Windows 11 Arm64 + Visual Studio 2026); every ARM64 failure so far came from a pre-build toolchain probe that
|
||||
aborted before `cargo build` ran, that probe is now evidence-only, and the next CI run is the first real answer to
|
||||
whether the slot can produce a binary. `linux-arm64` is built natively on `ubuntu-22.04-arm` (same glibc 2.35 floor
|
||||
as x64) and is unpublished for a different reason: **nothing has ever run it on ARM64 hardware**. One frame plus the
|
||||
theme/backend log lines on a real ARM64 machine is the unlock condition; until then the artifact is build evidence.
|
||||
|
||||
Each UNIX archive contains the executable plus `LICENSE`, `NOTICE` and `README.md`, in one top-level
|
||||
directory. Windows ships a bare `.exe` (a single file cannot carry the texts), so those three
|
||||
documents are attached to the release next to it — downloading the Windows binary does not strip the
|
||||
licence. Download links never carry implementation detail in the name, and the binary inside an
|
||||
archive is always called `unlucky-launcher`.
|
||||
|
||||
On Linux x64 that is the whole install:
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher_<version>_linux_x64.tar.gz
|
||||
cd unlucky-launcher_<version>_linux_x64
|
||||
./unlucky-launcher # add --software-renderer if there is no usable GPU adapter
|
||||
```
|
||||
|
||||
It asks for glibc 2.35+ (the slot is built on Ubuntu 22.04), `libxkbcommon`, `fontconfig` and `libGL`; on
|
||||
Debian/Ubuntu `sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6`
|
||||
covers the load-time set, and the bare `ull_<version>_linux_x64` beside the archive needs no display at all.
|
||||
|
||||
The two shapes are not duplicates. The GUI binary needs the graphics stack when it opens a window (it
|
||||
`dlopen`s X11 / Wayland / GL and links `libfontconfig` at load); `ull` needs nothing beyond glibc, so
|
||||
it runs on a server or a CI container where no window can exist. On Linux that floor is glibc ≥ 2.35
|
||||
(the slots build on Ubuntu 22.04) — an older distribution needs a source build
|
||||
(`packaging/README.md` §4). Pick `ull` when there is no desktop, `unlucky-launcher <command>` when
|
||||
there is. Both print the same CLI text and each names itself after the file you ran — `argv[0]` with
|
||||
its directory and a trailing `.exe` removed, nothing else (`crates/cli/src/cli/program.rs`), so the
|
||||
`ull` asset prints `ull ·`, the binary inside an archive prints `unlucky-launcher ·`, and a local
|
||||
`cargo build` prints `unlucky-launcher-iced ·`.
|
||||
|
||||
Minimum platform baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported. macOS 12+ on Apple
|
||||
Silicon and Intel, Linux x64 / arm64 (`crates/core/src/platform/os_info.rs` holds the baseline
|
||||
check). On Windows 10/11 the builds use the standard toolchain (MSVC / GNU), built the same way as
|
||||
upstream cargo. On Linux, X11 is the preferred path today and Wayland is the long-term direction.
|
||||
Capability differences are feature gates with runtime probing, never a compile-time "this platform
|
||||
gets less".
|
||||
|
||||
First run with a Microsoft account? Nothing to configure — official builds carry the client id; see
|
||||
[Accounts](#accounts). Nothing else needs configuring.
|
||||
|
||||
## Build from source
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
|
||||
`rustfmt` and `clippy` components)
|
||||
- Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
||||
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||
libxcb1-dev` — winit + wgpu/tiny-skia ask for these at build time
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # formatting
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
||||
cargo test --workspace # tests
|
||||
cargo build --release # binaries land in target/release/
|
||||
cargo deny check licenses bans sources # dependency licences (required in CI)
|
||||
```
|
||||
|
||||
What the build produces:
|
||||
|
||||
- A build from source carries no Microsoft client id by default. Set `ULL_MSA_CLIENT_ID` at build time
|
||||
to bake one in (`ULL_MSA_CLIENT_ID=<id> cargo build`); official release binaries already carry one
|
||||
— see [Accounts](#accounts).
|
||||
- `unlucky-launcher-iced` — the GUI, and the CLI in the same file: it answers every subcommand listed
|
||||
above. `iced` is cargo's bin name, not a download name — `packaging/make_artifacts.py` stages that
|
||||
same build as `unlucky-launcher_<version>_<platform>_<arch>` and names the binary
|
||||
`unlucky-launcher` inside the archive.
|
||||
- `ull` — the CLI as a standalone, UI-free binary. Same command set, no graphics stack, smaller, and
|
||||
the right artifact for a machine that has no display at all.
|
||||
|
||||
Rendering switches:
|
||||
|
||||
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
|
||||
`--screenshot <png>` writes one frame and exits — that is the acceptance channel CI's headless
|
||||
smoke job uses
|
||||
- With no usable adapter the iced build's explicit probe falls back to the CPU path on its own
|
||||
|
||||
**CI gates.** Every push to `main` and every pull request runs fmt / clippy (warnings are errors) /
|
||||
the full test suite, and the Linux headless render smoke is a hard gate on the same footing: it
|
||||
renders one real frame under Xvfb, then asserts the PNG is not blank, and a red result blocks the
|
||||
merge. The macOS probe is not there yet — manual dispatch only, result recorded but not enforced.
|
||||
|
||||
| Workflow | Content |
|
||||
|----------|---------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb, one real frame, PNG asserted non-blank); macOS runtime probe (manual dispatch only) |
|
||||
| `.github/workflows/guards.yml` | zero-UI guards (`crates/core`, `crates/presenter`, `crates/design`), 400-lines-per-file guard, cargo-deny licence and source guard |
|
||||
| `.github/workflows/release.yml` | tag `v*` → build the four default slots (`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`) on native runners → single-file artifacts + `SHA256SUMS.txt` → GitHub Release (publishes `windows-x64` and `linux-x64`; `linux-x64` also gets a per-slot Xvfb first-frame gate; `-f all_slots=true` builds all six slots for evidence) |
|
||||
| `.github/workflows/release-guard.yml` | release-marker semantics: push tag ⇒ a **stable** release, dispatch `prerelease=true` ⇒ pre-release; the table lives in `packaging/release_marker.py` and the guard mutation-tests itself |
|
||||
|
||||
## Architecture and module map
|
||||
|
||||
```
|
||||
Unlucky-Launcher (cargo workspace)
|
||||
├── crates/core # domain library, zero UI dependencies, eight modules
|
||||
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core)
|
||||
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
|
||||
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
|
||||
└── packaging # single-file artifact matrix and packaging assets (planned)
|
||||
├── crates/core # domain library: zero UI dependencies, eight modules
|
||||
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
|
||||
├── crates/presenter # business host layer: framework-free state / host / dto / conversion
|
||||
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
|
||||
│ # / component metrics — zero UI dependencies, no third-party crates
|
||||
├── crates/i18n # language detection, message catalogue, CJK font policy
|
||||
├── crates/palette-extract # the only crate that decodes images, for the wallpaper colour pipeline
|
||||
├── crates/app-iced # the iced UI (bin `unlucky-launcher-iced`): the only GUI the workspace builds
|
||||
├── crates/cli # CLI (bin `ull`): headless, zero UI
|
||||
└── packaging # single-file artifact matrix and release scripts
|
||||
```
|
||||
|
||||
Layering: `app-iced` → `presenter` → `core` / `agent`. `design` and `i18n` are consumed by the shell
|
||||
and the presenter; none of them may depend on a rendering framework, and CI guards enforce it.
|
||||
|
||||
The eight modules of `crates/core`:
|
||||
|
||||
| Module | Responsibility |
|
||||
@@ -63,62 +422,25 @@ The eight modules of `crates/core`:
|
||||
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
|
||||
| `game` | version / instance repository, snapshots and inheritance resolution |
|
||||
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
|
||||
| `auth` | Microsoft sign-in / local offline profiles |
|
||||
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
|
||||
| `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services), third-party community servers, local profiles |
|
||||
| `addon` | mod loader installation, modpack import, CurseForge / Modrinth, pack export |
|
||||
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
|
||||
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
|
||||
|
||||
## Building
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` /
|
||||
`clippy`)
|
||||
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
||||
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
||||
(Slint winit backend + femtovg build dependencies)
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # formatting
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
||||
cargo test --workspace # tests
|
||||
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
|
||||
```
|
||||
|
||||
Software-rendering switch (machines without a usable GPU / with broken drivers):
|
||||
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
|
||||
|
||||
Dependency license check (optional locally, required in CI):
|
||||
`cargo deny check licenses bans sources`.
|
||||
|
||||
## CI gates
|
||||
|
||||
| Workflow | Content |
|
||||
|----------|---------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
|
||||
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
|
||||
|
||||
## Platform baseline
|
||||
|
||||
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported:
|
||||
|
||||
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no
|
||||
`-Zbuild-std`, no import-table allowlists
|
||||
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
|
||||
direction; Android Termux experimental, not committed
|
||||
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
|
||||
drivers)
|
||||
- Platform independence is expressed through **separate test matrices**, not separate artifact
|
||||
pipelines; capability differences use feature gates with runtime probing
|
||||
|
||||
## License and acknowledgements
|
||||
|
||||
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE)
|
||||
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
|
||||
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
|
||||
serve the attribution duty.
|
||||
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
|
||||
[NOTICE](NOTICE).
|
||||
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the UI, together with `winit`
|
||||
(Apache-2.0) and, through it, `wgpu` (MIT / Apache-2.0) / `tiny-skia` (BSD-3-Clause).
|
||||
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
|
||||
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
|
||||
[NOTICE](NOTICE).
|
||||
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
|
||||
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
|
||||
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
|
||||
GPL-family dependencies may not enter the main line.
|
||||
|
||||
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and
|
||||
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
|
||||
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
|
||||
implementation from them, and this project is dedicated to those two pioneers.
|
||||
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
|
||||
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
|
||||
project has benefited a great deal from their design choices and implementation details.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Unlucky Launcher · Termux 适配(`termux-glibc` 分支)
|
||||
|
||||
这条分支把启动器带到 **Termux(Android)**:用 glibc 工具链编出 `aarch64-unknown-linux-gnu`
|
||||
二进制,在 **termux-x11** 里跑 GUI;同时补上 Termux 平台识别、系统 JDK 安装与中文字体。
|
||||
|
||||
> 给主线的改动清单、兼容性说明与合并指南见
|
||||
> [`TERMUX-ADAPTATION.md`](TERMUX-ADAPTATION.md)。
|
||||
|
||||
## 已验证
|
||||
|
||||
| 项 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 源码构建(GUI + CLI) | ✅ | Rust 1.99.1 gnu + clang/lld + glibc 运行时;GPU 构建(wgpu)默认带 CPU 兜底 |
|
||||
| 窗口显示 | ✅ | termux-x11 `:0`,横屏 1657×1067,首帧 ~60 ms(GPU) |
|
||||
| 中文 | ✅ | 启动时注册 `/system/fonts/NotoSansCJK-Regular.ttc` |
|
||||
| 微软登录 id | ✅ | 构建期注入,日志 `client_id=configured source=build` |
|
||||
| 横屏适配 | ✅ | 默认窗口按 X 屏幕等比自适应(`source=termux-screen`) |
|
||||
| Java 发现 | ✅ | `JAVA_HOME`/PATH + `$PREFIX/lib/jvm` 扫描 |
|
||||
| Java 自动安装 | ✅ | `ull java install 17` 由内置镜像装完 openjdk-17(约 20 s) |
|
||||
| GPU(wgpu) | ✅ | 自编 Mesa 26.2.6 + KGSL;wgpu 选中 Adreno 840(Vulkan/turnip) |
|
||||
| Minecraft 运行 | ✅ | 真机 1.20.4 跑通:glibc JRE + arm64 natives + softpipe GL,主菜单在 X11 渲染 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
完整配方(工具链、patchelf、linker、门禁)见
|
||||
[`packaging/docs/termux-glibc-build.md`](packaging/docs/termux-glibc-build.md)。最短路径:
|
||||
|
||||
```sh
|
||||
termux-x11 :0 -ac &
|
||||
DISPLAY=:0 ./unlucky-launcher-iced # 默认 auto:有驱动走 GPU,没有则自动降级 CPU
|
||||
```
|
||||
|
||||
- GPU 驱动不随 Termux 源提供,需要按构建文档 §5 自己编一次 Mesa 26 + ICD;
|
||||
不装也能跑,只是 `auto` 会落到 tiny-skia(CPU)。
|
||||
- 想强制某一侧:`--renderer=gpu` / `--software-renderer`。
|
||||
- 窗口尺寸默认按屏幕自适应;`ULL_WINDOW_SIZE=1520x900` 可覆盖。
|
||||
- Java:`ull java install 17|21|25` 在 Termux 上默认用系统包(内置镜像 + 隔离 apt 状态)。
|
||||
- **数据根**:Portable 模式按**可执行文件所在目录**生成 `.ULL` / `.minecraft`;
|
||||
换目录启动会看到另一套实例(`--data-dir` 目前接受并忽略)。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 仅单机(OnePlus 15 / Android 17)验证,试验级、不在发布矩阵内。
|
||||
- **GPU**:已打通,但驱动是自编的(Mesa 26.2.6 + KGSL),装到
|
||||
`$PREFIX/glibc/{lib,share/vulkan/icd.d}` 后应用会自动启用;Termux 官方源里的
|
||||
glibc Mesa 太旧(24.2.6 不认识 Adreno 840)。驱动缺失时自动降级 CPU,不会崩。
|
||||
- **MC**:Android 原生 natives 走不通(LWJGL 没有官方 Android/bionic 产物,且 GLFW 的
|
||||
窗口模型与 termux-x11 的 X11 不兼容)。有效路线是 glibc JVM,已真机跑通 1.20.4:
|
||||
Temurin 21 JRE(patchelf 后直接执行)+ Linux arm64 natives(启动器自动改写/镜像兜底)
|
||||
+ softpipe GL。非致命项:narrator 的 flite 缺失、LWJGL JNI 版本告警;
|
||||
softpipe 是软件渲染,帧率有限,zink(硬件 GL)仍卡 DRI3。
|
||||
- Termux 上三条测试失败是环境差异(`/proc/*/children`、长路径脱敏、mtime flaky),
|
||||
PC 的 Linux CI 不受影响。
|
||||
|
||||
## 后续
|
||||
|
||||
1. zink 的 DRI3 问题(硬件 GL;当前 softpipe 可跑但慢)——这是 MC 帧率的主要瓶颈
|
||||
2. 剪贴板/IME 在 termux-x11 下的行为验证
|
||||
3. narrator/flite 与音频设备(当前 missing 但不阻断)
|
||||
+346
-68
@@ -2,43 +2,352 @@
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
|
||||
> **官方项目主页**:<https://github.com/AskaEth/Unlucky-Launcher> | 下载、议题、发布说明都在这里:
|
||||
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
>
|
||||
> **本文档对应版本:`0.2.3`**——`unlucky-launcher --version` 打出来的是同一个号。
|
||||
|
||||
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
||||
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
||||
Unlucky Launcher 是一个用 Rust 写的免费开源 **Minecraft: Java 版**启动器(Apache-2.0)。每个平台就是
|
||||
**一个原生二进制**——没有安装包、没有捆绑运行时、没有 WebView——从装一个版本到读懂一次崩溃,它把启动器
|
||||
该做的事都覆盖了:实例与版本、加载器与整合包、Mod / 资源包 / 光影、逐实例 Java、微软 / 第三方社区服 /
|
||||
本地离线账户,以及一套在本地按 YAML 规则跑的崩溃诊断。你的数据不经过我们自己的任何服务:账户令牌只
|
||||
留在本机。
|
||||
|
||||
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
|
||||
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
|
||||
- 许可:Apache-2.0 © AskaEth (YeiJ)
|
||||

|
||||
|
||||
## 当前状态
|
||||
*实例页——1180×760,英文界面,浅色档。实例名是测试数据,不是谁的真实存档。图里那句
|
||||
`Launch failed · exit0 静默失败` 也正好是一处已知缺口的诚实样子:那行原因仍是启动层直接抛出来的中文
|
||||
原文(见[还没做](#还没做))。*
|
||||
|
||||
**核心引擎已完成**(workspace 983+ 测试全绿):
|
||||
## 现在能用什么
|
||||
|
||||
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态
|
||||
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
|
||||
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
|
||||
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
|
||||
- 多源下载引擎、崩溃诊断规则引擎
|
||||
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
|
||||
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
|
||||
### 实例、版本与启动
|
||||
|
||||
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。
|
||||
- **实例**——list-detail 版式:左侧是本地实例列表;右侧是所选实例的版本、加载器、隔离方式(独立游戏
|
||||
目录或共享目录)、文件是否在盘上、上次游玩时间,以及 Play / Stop。窄窗是把两个面板整页切换,不是上下堆。
|
||||
- **实例从哪来**——装一个版本(带实例名、加载器与加载器版本),或者导入一个整合包。实例页自己的
|
||||
**新建实例 / 导入 / 复制 / 打开文件夹 / 删除** 五个按钮**还没接后端**:它们是占位动作,点了只往
|
||||
stderr 写一行(`crates/app-iced/src/app/update/page_messages.rs`),欠的是实例向导
|
||||
(`crates/app-iced/src/pages/mod.rs` 偏离 #8)。
|
||||
- **版本管理**——三栏(版本类型 / 主版本 / 搜索)读 Mojang 版本清单:正式版、快照、远古版;安装时可选
|
||||
加载器与加载器版本。取消安装是真停:在下一个段边界收手,在飞的那个文件就地掐断,且不登记实例
|
||||
(`crates/presenter/src/host/dispatch.rs:61-88`)。命令行还能只出计划不下载(`--dry-run`:按类的文件数
|
||||
与总大小,人类可读输出里每类给一个源链样本;整份文件清单在 `--json` 里)。
|
||||
- **启动**——10 阶段命令行组装(`crates/core/src/launch/`)、逐实例 JVM 与游戏参数、原生库解压落位,
|
||||
每次运行留一份 JSONL 会话记录。进程按**进程树**杀,不是只杀一个 PID。
|
||||
- **实例级启动参数**——JVM 参数、游戏参数、内存、Java 可执行文件与图形后端(`default` / `opengl` /
|
||||
`vulkan`)落在实例文件的 `launch.*` 里,**每项各自回落**:实例 → 全局设置 → 出厂默认,所以只填了内存的
|
||||
实例不会把全局 JVM 参数一起顶掉。JVM 参数是**追加**(全局先、实例后,JVM 同名选项后出现者赢);越界的
|
||||
内存值当作「没填」,而不是拿 `-Xmx0m` 把 JVM 启动弄崩;留空即「跟随」。游戏版本够低时后端那一行直接不
|
||||
出现。CLI 与 GUI 读的是同一份生效值(`crates/core/src/game/launch_overrides.rs`)。
|
||||
- **崩溃诊断是真的、也是本地的**——`crates/core/src/diagnose` 采五类信号(退出码、`latest.log`、
|
||||
`crash-reports`、`hs_err_pid`、含 mod 清单的环境快照),按 YAML 规则集匹配,把结论与修复动作显示在
|
||||
日志页上。不接模型、不联网:断网能用,没有 GPU 的机器也能用。
|
||||
|
||||
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面
|
||||
随后续版本交付——在此之前请使用 CLI。
|
||||
### 加载器、整合包与内容库
|
||||
|
||||
## 模块地图
|
||||
- **加载器**——Fabric、Quilt、NeoForge、Forge。需要跑官方 installer `processors` 的 Forge/NeoForge 版本
|
||||
会真跑一遍;机器上没有 Java 时先装一个托管 JDK。
|
||||
- **整合包**——导入 CurseForge 清单、Modrinth `.mrpack`、MultiMC/Prism 实例;导出为 `.mrpack` 或
|
||||
CurseForge 清单。**导入**的取消只是界面级:面板立刻收回、结果作废,但后台那次导入会跑到底——core 的
|
||||
导入请求还没有取消令牌(`crates/presenter/src/host/addon/pack.rs:28-42`)。
|
||||
- **导入冲突逐文件决策**——写盘之前先扫一遍:每个冲突文件(是什么、为什么冲突、多大)分页列出(每页 40
|
||||
条),三个入口定这一批——全部保留、全部覆盖、逐项选择(没点过的行留在「保留」)。不冲突的文件一律不重
|
||||
写,导入报告事后仍逐条列出全部冲突。
|
||||
- **内容库**——整合包 / Mod / 资源包 / 光影,带关键词、来源、版本、加载器与排序筛选,分页结果,
|
||||
以及一键**装入这个实例**。
|
||||
- **在线源:Modrinth(匿名)**——检索走 `/v2/search`,facets 从目标实例推出来(项目类型、游戏版本、
|
||||
加载器);详情与版本列表随后取,已知摘要的远端条目再用 `/v2/version_file/<hash>` 补全。不需要 API key,
|
||||
也不发 POST(下载引擎没有请求体字段,所以摘要一条一条 GET 查)。离线是**独立的一态**——「未联网」,
|
||||
绝不说成「没有匹配的内容」。
|
||||
- **这一页会拒绝什么,就写在那一行上**——整合包**不能**从内容库直接装(整合包走上面的导入管线);
|
||||
CurseForge 的「装入」是禁用态,因为它的文件端点还没接通;游戏版本或加载器对不上目标实例的条目也是
|
||||
禁用态,并把不匹配写清楚。没有一条是静默失败的。
|
||||
- **CurseForge 检索要 key**——启动前设 `ULL_CF_API_KEY`,没有它这个来源在来源筛选里是灰的。key 只活在
|
||||
环境变量里:不写进配置文件、不进日志(`crates/presenter/src/host/addon.rs`)。
|
||||
|
||||
### Java
|
||||
|
||||
逐实例的托管 JDK,四家发行版的回退链**按地区排**——Temurin 打头,国内是 Oracle 先于 Microsoft
|
||||
(那边实测快 28×)、国外反过来,Zulu 收尾——可用 `java.vendorPriority` 设置整体覆盖
|
||||
(`crates/core/src/game/java/vendor.rs`)。镜像也按地区选。`java list` 把它真能找到的每个 JDK 都列出来
|
||||
(包括官方启动器自己下的那些),`java install <主版本>` 拉一个。游戏要的 JDK 是启动器的数据,不是启动器
|
||||
本身的依赖。
|
||||
|
||||
### 账户
|
||||
|
||||
三条轨道在同一页:
|
||||
|
||||
- **微软**——OAuth 2.0 设备码流程 → Xbox Live → XSTS → `api.minecraftservices.com`。
|
||||
- **第三方社区认证服**(authlib-injector 一类)。
|
||||
- **本地离线档案**,用于隔离测试。
|
||||
|
||||
**微软登录在官方构建里开箱即用。** 发布版二进制已内置它需要的 Azure 公共客户端 id(公共客户端 id 不是
|
||||
凭据,本来就随二进制一起分发),所以登录前不需要任何配置。设 `ULL_MSA_CLIENT_ID=<id>` 仍可覆盖内置值:
|
||||
变量在启动时读取,改完要**重启启动器**。从源码构建时该变量有什么就烘什么(`ULL_MSA_CLIENT_ID=<id> cargo build`,
|
||||
经 `option_env!` 烧进二进制)。两处都没有 id 的构建点登录**不会联网**,弹窗直接说清是这一项没配:标题
|
||||
「微软登录未配置」,正文点名 `ULL_MSA_CLIENT_ID=<id>`(设好或构建时注入后重启),并带
|
||||
`代码:msa_not_configured`;它**不给重试按钮**,因为在同一个进程里重试必然是同一个错。其它登录失败仍是
|
||||
老样子:一句分类文案 + `代码:<码>`,完整原因只在启动器日志里。第三方社区服与本地档案不需要 id。
|
||||
|
||||
### 外观与语言
|
||||
|
||||
- **只有一套 UI 栈**——iced 0.14 + Material 3 Expressive,自绘标题栏、带动效的导航轨,浅色 / 深色 /
|
||||
跟随系统。
|
||||
- **外观**——窗口背景图与取色链路(面板配色可从壁纸推出来)、面板透明度、整窗透明度(Windows)。Windows 11
|
||||
与 Server 2025 上向系统请求外框圆角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。
|
||||
- **CPU 兜底**——`--software-renderer` 让没有可用 GPU 或驱动有问题的机器照样正常显示;没有可用适配器时,
|
||||
后端探测自己退到 CPU。这条路上**面板级**半透明与模糊会关掉(**窗口级**透明度照常可用),设置面板会写明。
|
||||
- **本地化**——跟随系统语言探测、设置里可显式切换、重启后保持。绝大多数文案来自同一份消息目录
|
||||
(`crates/i18n`)。**边界,因为上面这句很容易被读大**:防硬编码的守门
|
||||
(`crates/app-iced/src/pages/instances/tests/i18n.rs`)只扫实例页源码,不是每一屏;写死的中文字面量里
|
||||
没有 ASCII 单词可以匹配,这一类它抓不到;外观面板的老控件还是硬编码英文
|
||||
(`crates/app-iced/src/pages/appearance/section.rs`),中文界面下它们仍显示英文。
|
||||
|
||||
### 助手,以及 MCP server
|
||||
|
||||
这是两件不同的事,README 不该含糊:
|
||||
|
||||
- **启动器内的助手今天还是占位壳。** 它的 transport 是脚本化的
|
||||
(`crates/app-iced/src/presenter/transport.rs`),没有接真实模型,而且界面上它出现的每一屏都用四处明说
|
||||
这一点——副标题、输入框占位符、空态正文,以及一枚「模拟」胶囊。会话状态、未读数、草稿、确认流程与具名
|
||||
失败码都是真的 presenter 状态;缺的只是它们后面的模型。
|
||||
- **MCP server 是真的。** `ull agent serve` 把启动器内核以 MCP server 形态(stdio 或 Streamable HTTP)
|
||||
开放给外部 Agent 工具,只监听回环(`127.0.0.1`,默认端口 47823),除非
|
||||
`agent.mcp.allow_write=true` 否则只读。十个工具规格(`crates/agent/src/tools/`)同时喂给两种形态,两条
|
||||
路都过同一个执行器——schema 校验、敏感分级、审计留痕。`ull agent config` 能打印现成的 Claude Code /
|
||||
Cursor 配置片段。
|
||||
|
||||
### 命令行
|
||||
|
||||
GUI 二进制在跑任何 UI 代码之前就先认全部 CLI 子命令——不开窗、不探渲染器、不扫字体。另有一份更轻的 `ull`
|
||||
产物,命令集相同,却一个图形栈都不链接。
|
||||
|
||||
```bash
|
||||
unlucky-launcher # GUI
|
||||
unlucky-launcher --software-renderer # GUI,CPU 渲染
|
||||
unlucky-launcher instances list # CLI,不开窗
|
||||
unlucky-launcher --json launch 1.20.1 # CLI,全局选项排在子命令前面
|
||||
unlucky-launcher --version # CLI,版本 + 系统摘要
|
||||
```
|
||||
|
||||
判据刻意收得很窄,所以原本能用的用法一条都没变:
|
||||
|
||||
- 第一个参数是裸词(不以 `-` 开头)→ 它就在子命令的位置上,交给 CLI。GUI 的 flag 永远不会出现在那个位置:
|
||||
`--screenshot` / `--diff` / `--data-dir` 的取值都排在 flag 之后。
|
||||
- 第一个参数是 flag → 只有当 `crates/cli` 的解析器也解析出一条真命令时才算 CLI 调用(`--help`、
|
||||
`--version`、`--json instances list`)。单独 `--data-dir X` 解析成「没有子命令」,仍然开 GUI;GUI 专属
|
||||
参数(`--software-renderer`、`--screenshot*`、`--renderer`、`--theme`、`--diff`、`--force-state` 等)
|
||||
在 CLI 里一律解析失败,也仍然开 GUI。
|
||||
|
||||
**未知子命令是错误,不是开窗**:`unlucky-launcher lst` 打印「未知命令 `lst`」、提示
|
||||
`unlucky-launcher --help`,以 1 退出。静默开窗会让脚本以为自己那个拼错的参数生效了。子命令表只有一处定义
|
||||
(`crates/cli/src/cli/parse.rs`),所以两个二进制认的命令集天然一致。`ull --help` 会全列出来:`instances`、
|
||||
`install`、`launch`、`pack import`、`accounts`、`sessions`、`logs`、`diag`、`config`、`java`、`update`、
|
||||
`agent`、`version`、`help`。
|
||||
|
||||
### 自更新与稳定通道
|
||||
|
||||
- **自更新是真的,而且 GUI 与 CLI 共用同一个内核。** 设置页的「更新」组与 `ull update check` /
|
||||
`ull update apply` 造的是同一个 `core::update::Job`(`crates/core/src/update/`):取
|
||||
`releases/latest/download/manifest.json`、比版本、过渠道准入、校验长度 + SHA256,再原子换包——把旧文件
|
||||
改名挪开、把新的搬进去,失败就回滚。Windows 上正在运行的 exe 能**改名**、不能覆盖,所以换包是改名。
|
||||
- **四种结论不许混**:已是最新 / 有可装的更新 / 有更高版本但里面没有本平台产物 / 检查本身失败(断网、清单坏、
|
||||
渠道不符)。失败绝不会被说成「已是最新」。
|
||||
- **只发布 stable 通道。** `update.channel` 默认 `stable`;beta / alpha / nightly 已建模但不发。清单准入要两条
|
||||
同时成立——清单自称的渠道与版本号里的预发布阶段:清单会撒谎,版本号不会。
|
||||
- **为什么发布必须被标成稳定版**:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而清单
|
||||
地址就钉在它上面。`0.2.3` 是第一个不带预发布标记建出来的版本(`0.2.2` 保留为历史预发布)。「哪种触发创建什么
|
||||
标记」只有一张表(`packaging/release_marker.py`),并由 `packaging/check_release_semantics.py` 守着。
|
||||
- **没有任何东西背着你自动安装。** 启动时的检查可以关掉(`update.auto_check`,CLI 读的是同一个键),安装要么是
|
||||
点按钮,要么是显式 `ull update apply`。调试构建可以拿本地目录源把整条链演练一遍(`ULL_UPDATE_DIR`);发布
|
||||
构建里**不存在**这条路径。
|
||||
|
||||
### 日志在哪
|
||||
|
||||
启动器自己这一侧写的东西全在 `<数据根>/logs/` 一个目录里。设置页有一行**日志文件夹**,直接显示这个
|
||||
路径,旁边是 *打开文件夹* 与 *复制路径*;日志页页头也有 *打开文件夹*。
|
||||
|
||||

|
||||
|
||||
*设置页 → 通用 → 日志文件夹。路径就是当前实际生效的日志根(便携 / 标准 / 自定义三种数据模式各自不同),
|
||||
打开失败时这一行照样显示,右按钮把路径送进剪贴板。*
|
||||
|
||||
数据根按三种模式取值:便携是 `<exe 目录>/.ULL`,标准是 `<用户应用数据目录>/ULL`,自定义是
|
||||
`<自定义根>/.ULL`——与设置页显示的「启动器数据目录」是同一个值。
|
||||
|
||||
| 路径 | 是什么 |
|
||||
|---|---|
|
||||
| `logs/launcher.log` | 启动器自己的运行日志:后端探测、字体/主题决策、后端选择、**渲染后端起不来时的具名原因**,以及每一次启动(命令行事实、spawn 结果、退出码)。满 1 MiB 滚成 `logs/launcher.1.log`,**只留一份**旧档,所以它不会无限长大。 |
|
||||
| `logs/launcher.1.log` | 上一份 1 MiB,下次滚动时被替换。 |
|
||||
| `logs/game/<session-id>.jsonl` | 每次启动一份 JSONL 事件流:命令行(已脱敏)、每一行 stdout/stderr、退出码。 |
|
||||
| `logs/game/index.jsonl` | 一会话一行摘要的索引,日志页左栏读的就是它。 |
|
||||
| `logs/install.log` | 安装流水:每个阶段一行。 |
|
||||
| `logs/session-log-location.json` | 会话日志现在在哪、以前在哪的记录,每次启动重写。 |
|
||||
| `data/agent/sessions/game/…` | **只读兼容**:旧版本写下的会话。仍然列得出来、打得开;不搬动、不删除任何一个文件。 |
|
||||
|
||||
游戏自己的文件不在这里,而在实例的运行目录里:`logs/latest.log`、`crash-reports/crash-*.txt`、
|
||||
`hs_err_pid*.log`。**`crash-reports/` 只在 JVM 活到那一步时才存在。** 模组、缺库或 Java 选错把游戏
|
||||
弄死在 Minecraft 初始化之前时,游戏根本没机会写它——那种时候诚实的证据是会话 JSONL 与
|
||||
`launcher.log`,不是崩溃报告。这是游戏的行为,不是启动器藏了什么。
|
||||
|
||||
### 还没做
|
||||
|
||||
这份清单是让人相信上面那些话的理由。没有一条藏在宣传话术后面。
|
||||
|
||||
- **没有安装包。** 下个文件直接跑——没有安装步骤;更新是就地替换那个文件,不装任何东西
|
||||
(见[自更新与稳定通道](#自更新与稳定通道))。
|
||||
- **更新链路只用本地目录源演练过。** 整条链(清单 → 下载 → SHA256 → 改名/回滚)是拿调试构建 + 夹具跑的,
|
||||
因为 `0.2.2` 还是预发布期间读不到真实的 `releases/latest/download/manifest.json`。线上稳定通道的第一次
|
||||
观察要等 `0.2.3` 发出去之后。
|
||||
- **有一批按钮是占位。** 实例页:*新建实例*、*导入*、*复制*、*打开文件夹*、*删除*。
|
||||
设置页框架:*保存*、*重置本组*、*添加覆盖*。点下去只往 stderr 写一行 `[page] 占位动作 …`,什么都不改
|
||||
——图形界面里的实例管理就是还欠着的那块实例向导;设置项是边改边生效,而不是靠「保存」。
|
||||
(日志页与设置页的「打开日志文件夹」**不再**是占位:它真的会打开 `<数据根>/logs`,见[日志在哪](#日志在哪)。)
|
||||
- **Linux x64 已发布,但「已发布」= 真能跑,不等于观感已定型。** 发布流水线默认就在原生 Ubuntu 22.04 上编
|
||||
`linux-x64` 并发出去,依据是:release 产物本身在真显示服务(WSLg)下起过窗、渲出一帧非空白画面、主题与渲染
|
||||
后端的日志行齐全(实测输出见 `packaging/docs/linux-evidence.md`),而且同一条判据已经常驻成发布流水线里
|
||||
**这个槽位**的门禁。还没人看过的:真实桌面发行版上的拖动/缩放,以及真桌面会话里的字体度量与中文整形。
|
||||
「整窗透明度」与「圆角」这两条在 Linux 上**根本没有代码路径**(不是未验):前者要窗口句柄,Linux 上拿不到
|
||||
(`hwnd=none`);后者交给桌面合成器(`corner=policy=system`),我们不自己画。
|
||||
`linux-arm64` 只是**编出来了,没发布**——没有任何 ARM64 机器跑过它一帧,产物只作构建证据留着。
|
||||
- **macOS 不由发布流水线构建,也未定型。** 有两轮真机证据(Apple Silicon),构建因此存在——但菜单栏显示名、
|
||||
三个窗口按钮的位置(macOS 习惯在左上,我们自绘在右上)、300s+ 稳态仍需真机复核。macOS 产物未签名,
|
||||
首次启动要自己清掉 Gatekeeper 隔离标记(`xattr -d com.apple.quarantine`)。macOS 运行期冒烟是手动
|
||||
`workflow_dispatch` 的探针、带 `continue-on-error`,不是门禁;`gh workflow run release.yml -f all_slots=true`
|
||||
能编这两档取证,但它们从不发布。
|
||||
- **内容库页在低窗口高度下会挤**,而且窗口高度还没接进页面模型——这一页按一个名义高度排版,而不是按它
|
||||
真正拿到的高度。
|
||||
- **启动侧失败提示未结构化。** 磁贴直接采用启动层抛出的中文句子
|
||||
(`crates/core/src/launch/exit_analysis.rs`),而不是一个界面能翻译的原因码——上面那张英文截图里的
|
||||
`静默失败` 就是它。
|
||||
- **启动器内的助手背后没有模型**(见上)。MCP server 有。
|
||||
- **内容库的安装进度是阶段级**,不是字节级。
|
||||
- **没有原生文件选择框。** 导入靠拖拽或粘贴路径。
|
||||
- **Android/Termux 是试验级、不承诺**:发布矩阵里没有面向它的资产。GUI 可以经 glibc 工具链源码自建(见 `packaging/docs/termux-glibc-build.md`,分支说明见 [`README.termux.md`](README.termux.md)),已真机验证两条路径各一次:CPU 软渲染,以及自编 Mesa/turnip 栈的 GPU 加速(证据见 `README.termux.md`)。
|
||||
|
||||
## 下载与运行
|
||||
|
||||
从 [Releases](https://github.com/AskaEth/Unlucky-Launcher/releases) 拿你那个平台的文件。文件名形如
|
||||
`unlucky-launcher_<版本>_<平台>_<架构>`;这个形状的唯一真相源是
|
||||
`packaging/make_artifacts.py::asset_stem()`:
|
||||
|
||||
| 槽位 | 产物 | 形态 |
|
||||
|------|------|------|
|
||||
| `windows-x64` | `unlucky-launcher_<版本>_windows_x64.exe` | 裸 `.exe`,双击即用 |
|
||||
| `linux-x64` | `unlucky-launcher_<版本>_linux_x64.tar.gz` | 单个归档,解压出一个顶层目录;要 glibc 2.35+ 与 X11/Wayland + fontconfig 那一套 |
|
||||
| `windows-arm64` | `unlucky-launcher_<版本>_windows_arm64.exe` | 裸 `.exe` —— **编了、不发布**,见下 |
|
||||
| `linux-arm64` | `unlucky-launcher_<版本>_linux_arm64.tar.gz` | 同上 —— **编了、不发布**(运行时零证据) |
|
||||
| `macos-arm64` | `unlucky-launcher_<版本>_macos_arm64.tar.gz` | 同上 —— 发布流水线**不构建** |
|
||||
| `macos-x64` | `unlucky-launcher_<版本>_macos_x64.tar.gz` | 同上 —— 发布流水线**不构建** |
|
||||
| 每个已发布槽位 | `ull_<版本>_<平台>_<架构>[.exe]` | 裸 CLI 文件,独立资产 |
|
||||
| 全平台 | `SHA256SUMS.txt` | `sha256sum -c` 可直吃 |
|
||||
|
||||
**本版发布 `windows-x64` 与 `linux-x64`,就这两档。** 发布 job 只留 `PUBLISH_SLOTS` 里点名的槽位
|
||||
(`.github/workflows/release-publish.yml`),其余产物在写 `SHA256SUMS.txt` 之前就被删掉——所以表里标
|
||||
**不发布** 的行是命名规则,不是本版可下载的资产。三条关系都有守卫(`packaging/check_publish_slots.py`):
|
||||
名单里点名了却没产物 ⇒ Release 变红(`linux-x64` 构建红了就挡住整次发布,靠的就是这条);产物在、名单没点名
|
||||
⇒ 显式打「未发布」警告,不再静默删掉;`mode: ci` 的槽位本次连产物都没有(那一档红了)⇒ 也打警告。
|
||||
|
||||
不发布的行里 CI 真编两档。`windows-arm64` 在 GitHub 原生 `windows-11-arm`(Windows 11 Arm64 + Visual Studio
|
||||
2026)上编来取证——**诚实一句**:此前每一次 arm64 红都红在**编译前的工具链体检**,`cargo build` 一次都没跑过;
|
||||
M164 把那个体检降级成只取证,所以**下一条 tag 的 CI 才是第一次真正问编译器**。`linux-arm64` 在原生
|
||||
`ubuntu-22.04-arm`(与 x64 同 glibc 地板)上编,不发布的理由完全不同:**没有任何 ARM64 机器跑过它**。
|
||||
解锁条件 = 真机渲出一帧 + 主题/后端日志行,之后才把键加进 `PUBLISH_SLOTS`。
|
||||
|
||||
Linux x64 的安装就是解开归档直接跑:
|
||||
|
||||
```bash
|
||||
tar -xzf unlucky-launcher_<版本>_linux_x64.tar.gz
|
||||
cd unlucky-launcher_<版本>_linux_x64
|
||||
./unlucky-launcher # 没有可用 GPU 适配器就加 --software-renderer
|
||||
```
|
||||
|
||||
载入期要 glibc 2.35+、`libxkbcommon`、`fontconfig`、`libGL`;Debian/Ubuntu 上
|
||||
`sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6` 就够。
|
||||
与归档并列的裸 `ull_<版本>_linux_x64` 连显示都不需要。
|
||||
|
||||
UNIX 归档里是二进制加 `LICENSE`、`NOTICE`、`README.md`,都在一个顶层目录下。Windows 发的是裸 `.exe`
|
||||
(一个文件装不下这三份文本),所以这三份作为并列资产挂在 Release 上——下 Windows 二进制不会把许可条款
|
||||
弄丢。下载链接里不带实现细节字样,归档里的二进制名永远是 `unlucky-launcher`。
|
||||
|
||||
这两种形态不是重复品。GUI 二进制开窗时要用图形栈(X11 / Wayland / GL 走 `dlopen`,启动时直接链接
|
||||
`libfontconfig`);`ull` 除了 glibc 什么都不需要,所以在没有窗口的服务器、CI 容器上跑得起来。Linux 上这个
|
||||
地板是 glibc ≥ 2.35(槽位在 Ubuntu 22.04 上构建)——更老的发行版得源码自建(`packaging/README.md` §4)。
|
||||
没有桌面就用 `ull`,有桌面就用 `unlucky-launcher <子命令>`。两者打出来的 CLI 文案是同一套,自称的名字跟着
|
||||
**你敲的那个文件**走——`argv[0]` 去掉目录与结尾的 `.exe`,别的都不剥
|
||||
(`crates/cli/src/cli/program.rs`),所以 `ull` 打印 `ull ·`,归档里那个二进制打印
|
||||
`unlucky-launcher ·`,本地 `cargo build` 出来的打印 `unlucky-launcher-iced ·`。
|
||||
|
||||
最低平台基线:**Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。macOS 12+(Apple Silicon 与 Intel),
|
||||
Linux x64 / arm64(基线判定在 `crates/core/src/platform/os_info.rs`)。Windows 10/11 的产物由标准工具链
|
||||
(MSVC / GNU)构建,构建方式与上游 cargo 一致。Linux 上 X11 是当前首选路径,Wayland 是大方向。能力差异
|
||||
一律用 feature gate + 运行期探测表达,绝不做成「这个平台天生少一块」的编译期裁剪。
|
||||
|
||||
第一次用微软账户?不用配置——官方构建已内置客户端 id,见[账户](#账户)。其它都不需要配置。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
前置:
|
||||
|
||||
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
|
||||
- Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
||||
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||
libxcb1-dev`——winit + wgpu/tiny-skia 编译期要用
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # 格式
|
||||
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
||||
cargo test --workspace # 测试
|
||||
cargo build --release # bin 落在 target/release/
|
||||
cargo deny check licenses bans sources # 依赖许可(CI 必跑)
|
||||
```
|
||||
|
||||
编出来的 bin:
|
||||
|
||||
- 从源码构建默认不带微软客户端 id:想让产物自带,就在构建时设 `ULL_MSA_CLIENT_ID`(`ULL_MSA_CLIENT_ID=<id>
|
||||
cargo build`);官方发布产物已内置,见[账户](#账户)。
|
||||
- `unlucky-launcher-iced`——GUI,同时也是 CLI:同一个文件认上面列的全部子命令。名字里的 `iced` 是 cargo
|
||||
的 bin 名,不是下载名——`packaging/make_artifacts.py` 把同一次构建落成
|
||||
`unlucky-launcher_<版本>_<平台>_<架构>`,归档里的二进制名写成 `unlucky-launcher`。
|
||||
- `ull`——CLI 的独立无 UI 构建:同一套命令、不链接图形栈、体积更小,给完全没有显示设备的机器用。
|
||||
|
||||
渲染开关:
|
||||
|
||||
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
|
||||
`--screenshot <png>` 出一帧图后退出——这是 CI 无头冒烟 job 用的验收通道
|
||||
- 没有可用适配器时,iced 版靠显式后端探测自己退到 CPU
|
||||
|
||||
**CI 门禁。** 每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头
|
||||
渲染冒烟与它们同级、是硬门禁:在 Xvfb 里真渲染一帧,再断言 PNG 非空白,红了就不许合。macOS 探针还不算
|
||||
——只手动 dispatch,结果留证据但不作为流水线结论。
|
||||
|
||||
| Workflow | 内容 |
|
||||
|----------|------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 真渲染一帧、断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||
| `.github/workflows/guards.yml` | `crates/core` / `crates/presenter` / `crates/design` 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 构建默认四档(`windows-x64` + `windows-arm64` + `linux-x64` + `linux-arm64`,各自原生 runner)→ 单文件产物 + `SHA256SUMS.txt` → GitHub Release(发布 `windows-x64` 与 `linux-x64`;`linux-x64` 另有一条同槽位的 Xvfb 首帧门禁;`-f all_slots=true` 才整表六槽位取证) |
|
||||
| `.github/workflows/release-guard.yml` | 发布标记语义守门:push tag ⇒ **稳定版**、dispatch `prerelease=true` ⇒ 预发布;标记表在 `packaging/release_marker.py`,守门会对自己做变异自测 |
|
||||
|
||||
## 架构与模块地图
|
||||
|
||||
```
|
||||
Unlucky-Launcher (cargo workspace)
|
||||
├── crates/core # 领域库:零 UI 依赖,八模块
|
||||
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
||||
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
|
||||
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
|
||||
└── packaging # 单文件产物矩阵与打包资源(规划中)
|
||||
├── crates/core # 领域库:零 UI 依赖,八模块
|
||||
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
||||
├── crates/presenter # 业务宿主层:与框架无关的 state / host / dto / 转换
|
||||
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
|
||||
│ # —— 零 UI 依赖、零第三方 crate
|
||||
├── crates/i18n # 语言探测、消息目录、中文字体策略
|
||||
├── crates/palette-extract # 唯一解码图片的 crate,供壁纸取色链路使用
|
||||
├── crates/app-iced # iced UI(bin `unlucky-launcher-iced`):workspace 里唯一的 GUI bin
|
||||
├── crates/cli # CLI(bin `ull`):headless,零 UI
|
||||
└── packaging # 单文件产物矩阵与发布脚本
|
||||
```
|
||||
|
||||
层间纪律:`app-iced` → `presenter` → `core` / `agent`。`design` 与 `i18n` 只被壳层与 presenter 消费;
|
||||
它们都不得反过来依赖渲染框架,CI 守门会拦。
|
||||
|
||||
`crates/core` 的八个模块:
|
||||
|
||||
| 模块 | 职责 |
|
||||
@@ -47,53 +356,22 @@ Unlucky-Launcher (cargo workspace)
|
||||
| `download` | 多源健康度、镜像、分块下载、边下边算 |
|
||||
| `game` | 版本/实例仓库、快照与继承解析 |
|
||||
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
|
||||
| `auth` | Microsoft 正版登录 / 本地离线档案 |
|
||||
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
|
||||
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services)、第三方社区服、本地档案 |
|
||||
| `addon` | 加载器安装、整合包、CurseForge / Modrinth、整合包导出 |
|
||||
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
|
||||
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
|
||||
|
||||
## 构建
|
||||
|
||||
前置:
|
||||
|
||||
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`)
|
||||
- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
||||
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
||||
(Slint winit 后端 + femtovg 编译依赖)
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # 格式
|
||||
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
||||
cargo test --workspace # 测试
|
||||
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
|
||||
```
|
||||
|
||||
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
|
||||
(等价于 `SLINT_BACKEND=winit-software`)。
|
||||
|
||||
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
| Workflow | 内容 |
|
||||
|----------|------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
|
||||
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
|
||||
|
||||
## 平台基线
|
||||
|
||||
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内:
|
||||
|
||||
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单
|
||||
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
|
||||
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
|
||||
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
|
||||
|
||||
## 许可证与致谢
|
||||
|
||||
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
|
||||
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
|
||||
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
|
||||
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
|
||||
- **iced**(<https://github.com/iced-rs/iced>),MIT:现役 UI 的框架;与它同栈的 `winit`(Apache-2.0)、
|
||||
以及经它引入的 `wgpu`(MIT / Apache-2.0)/ `tiny-skia`(BSD-3-Clause)。
|
||||
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
|
||||
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
|
||||
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
|
||||
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
|
||||
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
|
||||
|
||||
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。
|
||||
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
|
||||
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
|
||||
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
|
||||
本项目在设计取舍与实现细节上都从中受益良多。
|
||||
@@ -0,0 +1,144 @@
|
||||
# Termux 适配:方案、改动清单与合并指南
|
||||
|
||||
> 分支:`termux-glibc`(基于 `origin/dev`,15 个提交,分叉点 `87ab878`,合并前 HEAD `e987e02`)
|
||||
> 目的:让主线快速了解这组代码改了什么、为什么、怎么验证、如何合并。
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **构建**:为 Termux 编出 `aarch64-unknown-linux-gnu`(glibc)二进制,在 termux-x11 里跑 GUI。
|
||||
Android 原生 target 走不通(winit 的 android 后端需要 NativeActivity),所以不用它。
|
||||
- **已跑通**:GUI(CPU 与 GPU 两条渲染路径)、中文、横屏自适应、JDK 发现与系统包安装、
|
||||
MC 1.20.4(glibc JRE + arm64 natives + softpipe GL,真机进游戏)。
|
||||
- **未收口**:MC 启动参数支持(由主线后续补)、MC 硬件 GL(zink 卡 DRI3)、剪贴板/IME。
|
||||
- **对主线的影响**:所有行为都由 **运行时 Termux 识别 / `cfg(target_os = "linux")` /
|
||||
文件存在** 三选一 gate 住,Windows / macOS / 普通 Linux 的默认路径不变。
|
||||
|
||||
## 方案
|
||||
|
||||
### 1. 平台识别
|
||||
|
||||
`crates/core/src/platform/termux.rs`:用 `TERMUX_VERSION` 或默认 `PREFIX`
|
||||
(`/data/data/com.termux/files/usr`)识别 Termux,推导 `$PREFIX` 与 `$PREFIX/lib/jvm`。
|
||||
不把路径写死到调用方,也不改 `OsFamily`(Android 上仍按 Linux 家族处理)。
|
||||
|
||||
### 2. JDK:发现 + 安装
|
||||
|
||||
- **发现**:`game/java/discover.rs` 的 Unix 扫描根追加 `$PREFIX/lib/jvm`,
|
||||
环境可注入(`standard_roots_in`)。补上「Termux 装了 openjdk 但没设 JAVA_HOME/PATH」的盲点。
|
||||
- **安装**:新增 `game/java/termux.rs`。Termux 的 openjdk 是 `.deb`(bionic),
|
||||
与 Temurin 的 zip/tar.gz 不是同一形态,所以**委托包管理器**:
|
||||
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序 `apt-get update && install`;
|
||||
- lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不污染用户 apt 配置;装完清理;
|
||||
- 装完从 `$PREFIX/lib/jvm` 定位,幂等(已装则不走网络)。
|
||||
- **vendor 接线**:`JdkVendor::Termux`(17/21/25)。Termux 上缺省安装先试系统包,
|
||||
失败再走归档链;`--vendor termux` 只走系统包。设置白名单与契约测试同步。
|
||||
|
||||
### 3. 字体
|
||||
|
||||
`crates/app-iced/src/fonts.rs`:Termux 上把 `/system/fonts/NotoSansCJK-Regular.ttc`
|
||||
注册进 cosmic-text(fontdb 不扫 Android 字体目录),中文不再显示方框。
|
||||
|
||||
### 4. 窗口(横屏优先)
|
||||
|
||||
`app/boot/termux_window.rs`:Termux 上默认窗口按当前 X 屏幕等比自适应
|
||||
(日志 `size_source=termux-screen`),横屏铺满、竖屏不溢出;
|
||||
`ULL_WINDOW_SIZE` 覆盖与 1180×760 验收基准不变。
|
||||
|
||||
### 5. GPU(启动器)
|
||||
|
||||
- 驱动是自编的 **Mesa 26.2.6 + KGSL**,装在 `$PREFIX/glibc/{lib,share/vulkan/icd.d}`;
|
||||
构建/安装步骤见 `packaging/docs/termux-glibc-build.md` §5。
|
||||
- `app/boot/termux_gpu.rs`:glibc 构建 + Termux + ICD 文件存在时,在探测前设
|
||||
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`;用户显式设置不覆盖。
|
||||
- 实测 wgpu 选中 Adreno 840(turnip Mesa 26.2.6);无驱动自动降级 tiny-skia。
|
||||
|
||||
### 6. Minecraft 运行链
|
||||
|
||||
- **natives**:1.19+ 的清单把 natives 作为独立库条目(`org.lwjgl:...:natives-linux`),
|
||||
Linux/aarch64 上改写成 `natives-linux-arm64`(`Library::linux_arm64_native_name`,
|
||||
install 与 launch 共用);Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,
|
||||
下载兜底到阿里云 Maven(`install/libraries.rs`)。
|
||||
- **JVM**:用 glibc 的 Temurin JRE(patchelf 后直接执行),bionic 的 Termux JVM
|
||||
加载不了 glibc natives。
|
||||
- **GL**:glibc Mesa 的 zink 在 termux-x11 下卡 `DRI3 not available`,
|
||||
子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast` + `LIBGL_ALWAYS_SOFTWARE=1`
|
||||
(`launch/defaults.rs`,用户自定义环境变量可覆盖)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 | 平台隔离 |
|
||||
| --- | --- | --- |
|
||||
| `core/platform/termux.rs`(新) | Termux 识别、`$PREFIX`/JVM 根 | 纯函数,按环境判断 |
|
||||
| `core/platform/mod.rs` | 导出新模块 | — |
|
||||
| `core/game/java/discover.rs` | `$PREFIX/lib/jvm` 扫描 | Unix + Termux 判断 |
|
||||
| `core/game/java/termux.rs`(新) | 镜像/隔离 apt 安装、`installed_home` | 仅 Termux 可执行 |
|
||||
| `core/game/java/vendor.rs` / `source.rs` / `install.rs` | `JdkVendor::Termux` 与安装分派 | Termux 上才启用 |
|
||||
| `core/setting/keys.rs` + 契约测试 | 白名单加 `termux` | 跨平台可选,非 Termux 明确报错 |
|
||||
| `cli/commands/java_vendors.rs`、`cli/cli/help.rs`、`presenter/host/java.rs` | 展示与帮助文案 | 展示层 |
|
||||
| `core/game/library.rs` | `linux_arm64_native_name` / 分类器改写 | 仅 linux+arm64 |
|
||||
| `core/game/install/libraries.rs` | 现代 natives 条目改写 + Maven 兜底 | 仅 linux+arm64 |
|
||||
| `core/launch/defaults.rs` | Termux softpipe 环境默认值 | `cfg(linux)` + Termux |
|
||||
| `presenter/host/launch.rs`、`cli/commands/snapshot.rs` | classpath 用改写后的 arm64 坐标 | 仅 linux+arm64 |
|
||||
| `app-iced/fonts.rs` | Android CJK 字体注册 | 仅 Termux |
|
||||
| `app-iced/boot/termux_window.rs`(新) | 横屏自适应 | 仅 Termux |
|
||||
| `app-iced/boot/termux_gpu.rs`(新) | 自动启用自编 ICD | 仅 glibc+Termux+文件存在 |
|
||||
| `app-iced/Cargo.toml` | 新增 `unlucky-core` 依赖 | 复用平台识别 |
|
||||
| `README.termux.md`(新)、`packaging/docs/termux-glibc-build.md`(新) | 分支说明与构建配方 | 文档 |
|
||||
| `README.md` / `README.zh-CN.md` / 支持矩阵两处 | Termux 行同步 | 文档 |
|
||||
|
||||
## 行为变化与兼容性
|
||||
|
||||
- 新增 `JdkVendor::Termux` 会改变 `VENDOR_IDS` / `JDK_VENDORS` 的允许值集合
|
||||
(契约测试已同步)。非 Termux 上显式选它会在安装时明确报「只在 Termux 里可用」,
|
||||
默认回退序**不含** termux,PC 行为不变。
|
||||
- Linux/aarch64 的 LWJGL3 natives 会被改写;x86_64、Windows、macOS 不受影响。
|
||||
- `app-iced` 直接依赖 `unlucky-core`(此前通过 cli/presenter 间接依赖)。
|
||||
- 所有 Termux 专属路径都需要运行时识别或文件存在才会生效;普通 Linux 桌面不会触发。
|
||||
|
||||
## 合并指南
|
||||
|
||||
1. ~~`git fetch origin && git rebase origin/dev`~~ → **实际按用户令走 `--no-ff` 合并**
|
||||
(M156):`git merge --no-ff origin/termux-glibc`,15 个原提交一字未改地留在历史里,
|
||||
按平台识别 → 字体 → 文档 → Java → GPU → natives → MC 的分组可整组 revert。
|
||||
与 dev 前进的 12 个提交**零文本冲突**(交集只有 `Cargo.lock` / 两个 README /
|
||||
`crates/app-iced/Cargo.toml`)。
|
||||
2. PR base:`dev`。CI 门禁:`fmt` / `clippy --workspace --all-targets -D warnings` /
|
||||
`test --workspace --all-targets` / `cargo deny`(本机没装 deny,PC 上补跑)。
|
||||
3. 本机已知、**不影响 PC CI** 的三条失败:
|
||||
- `unlucky-core --test launch`:`/proc/<pid>/task/<pid>/children`(Android 内核没有)
|
||||
- 同测试:长路径被脱敏成 `<opaque>`
|
||||
- `presenter settings_bridge`:mtime 热重载偶发(flaky,单跑 3 次 2 过 1 挂)
|
||||
4. **不要提交**:`dist/`、`.gnu/`、任何 `ULL_MSA_CLIENT_ID` 值(仓库 Secret)。
|
||||
5. 合并后建议真机再跑一次:`--software-renderer` 截图 + `ull java install 17` + MC 启动。
|
||||
6. **PC 门禁发现的两处、已在 M156 就地修掉**(都只在 Windows/PC 侧才暴露,
|
||||
所以在 Termux 上跑门禁看不出来):
|
||||
- `game/java/discover.rs` 的两个 `#[cfg(unix)]` 测试,导入没跟着 gate
|
||||
→ Windows 上 `-D warnings` 判「未使用导入」。已把两个 `use` 同样 gate 成 `#[cfg(unix)]`;
|
||||
代价是这两个用例在 Windows 上不编译(由 Linux CI 覆盖)。
|
||||
- 本分支把 `game/java/install.rs`(456 行)与 `game/install/libraries.rs`(409 行)
|
||||
顶过了 400 行红线(`guards.yml` 的 `file-size`)。已按步骤拆出
|
||||
`install/termux_install.rs`、`install/one.rs`、`install/libraries/arm64_natives.rs`,
|
||||
行为不变、用例原样留在原处。
|
||||
|
||||
## 验证矩阵(本机)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| GUI 截图(横屏 1657×1067) | ✅ 中文正常,`client_id=configured source=build` |
|
||||
| GPU 渲染 | ✅ wgpu 选中 Adreno 840(turnip Mesa 26.2.6);降级路径也验证过 |
|
||||
| `ull java install 17` | ✅ SAU 镜像约 20 s,装完清理,`java list` 可见 |
|
||||
| MC 1.20.4 | ✅ 真机安装(740 MB)→ 主菜单 → 建世界 → 进游戏 |
|
||||
| fmt / clippy / 核心测试 | ✅ 475 条核心测试通过,clippy 零告警 |
|
||||
|
||||
## 后续路线
|
||||
|
||||
1. **MC 启动参数**(主线功能,另行排期):当前 `ull launch` 不接受自定义 JVM/游戏参数。
|
||||
2. **MC 硬件 GL**:修 glibc GLX 的 DRI3(让 zink 生效),或改用 VulkanMod。
|
||||
3. 剪贴板 / IME 在 termux-x11 下的行为验证。
|
||||
4. narrator/flite 与音频设备(当前缺失但不阻断)。
|
||||
5. GPU 驱动分发:目前每次都要按文档自编 Mesa;可考虑脚本化/预编译产物。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
全部改动按提交分组(platform → fonts → docs → java → gpu → natives → mc),
|
||||
可按组 revert;Termux 专属逻辑都有运行时/文件存在 gate,回滚不会波及 PC 路径。
|
||||
@@ -13,7 +13,7 @@ publish.workspace = true
|
||||
# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现
|
||||
# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。
|
||||
[dependencies]
|
||||
unlucky-core = { path = "../core", version = "0.1" }
|
||||
unlucky-core = { path = "../core", version = "0.2.3" }
|
||||
|
||||
# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars),
|
||||
# 工具结果 / 审计记录 / 事件流都用 serde derive。
|
||||
|
||||
@@ -45,7 +45,7 @@ pub use chat::{
|
||||
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
|
||||
};
|
||||
pub use exec::{ToolExecOutcome, ToolExecutor};
|
||||
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
|
||||
|
||||
/// 一轮对话最多几轮工具调用。
|
||||
pub const DEFAULT_MAX_STEPS: usize = 8;
|
||||
|
||||
@@ -14,6 +14,11 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[path = "provider/failure.rs"]
|
||||
mod failure;
|
||||
|
||||
pub use failure::ProviderFailure;
|
||||
|
||||
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
|
||||
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
|
||||
/// Ollama 默认端点(research/02 §5.2)。
|
||||
@@ -54,6 +59,35 @@ impl ProviderKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置键 `ai.provider` 的取值 → 形态(M174)。
|
||||
///
|
||||
/// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成
|
||||
/// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表
|
||||
/// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里
|
||||
/// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。
|
||||
/// 未知值给 `None`(由调用方回落默认,不猜)。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Option<Self> {
|
||||
match value.trim() {
|
||||
"openai_compatible" => Some(Self::OpenAiCompatible),
|
||||
"anthropic" => Some(Self::Anthropic),
|
||||
"ollama" => Some(Self::Ollama),
|
||||
"disabled" => Some(Self::Disabled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。
|
||||
#[must_use]
|
||||
pub fn setting_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "openai_compatible",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::Ollama => "ollama",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页展示名。
|
||||
#[must_use]
|
||||
pub fn display_name(self) -> &'static str {
|
||||
@@ -174,18 +208,22 @@ impl ProviderConfig {
|
||||
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
|
||||
pub fn validate(&self) -> Result<(), ProviderError> {
|
||||
if !self.is_enabled() {
|
||||
return Err(ProviderError::new(
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
|
||||
));
|
||||
}
|
||||
if self.model.trim().is_empty() {
|
||||
return Err(ProviderError::new("没有配置模型名(model)"));
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
"没有配置模型名(model)",
|
||||
));
|
||||
}
|
||||
if self.resolved_base_url().trim().is_empty() {
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要填写端点(base_url)",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要填写端点(base_url)", self.kind.display_name()),
|
||||
));
|
||||
}
|
||||
if self.kind.requires_api_key()
|
||||
&& self
|
||||
@@ -193,10 +231,10 @@ impl ProviderConfig {
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
{
|
||||
return Err(ProviderError::new(format!(
|
||||
"{} 需要 API key",
|
||||
self.kind.display_name()
|
||||
)));
|
||||
return Err(ProviderError::classified(
|
||||
ProviderFailure::NotConfigured,
|
||||
format!("{} 需要 API key", self.kind.display_name()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -239,20 +277,38 @@ impl fmt::Debug for ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
|
||||
/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的
|
||||
/// `ErrorReport` 翻译,见 [`failure`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderError {
|
||||
kind: ProviderFailure,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProviderError {
|
||||
/// 从可读文本构造。
|
||||
/// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: ProviderFailure::Other,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
|
||||
#[must_use]
|
||||
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败分类(「测试连接」按它分档)。
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> ProviderFailure {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderError {
|
||||
@@ -264,77 +320,5 @@ impl fmt::Display for ProviderError {
|
||||
impl std::error::Error for ProviderError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_defaults_match_research() {
|
||||
assert_eq!(
|
||||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||||
LLAMACPP_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||||
OLLAMA_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||||
ANTHROPIC_DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_model_base_url_and_key() {
|
||||
assert!(!ProviderConfig::disabled().is_enabled());
|
||||
assert!(ProviderConfig::disabled().validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||||
// OpenAI 兼容必须显式给端点
|
||||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||||
assert!(ProviderConfig::anthropic("claude")
|
||||
.with_api_key("sk-ant")
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_leaks_key() {
|
||||
let config =
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.with_api_key("sk-super-secret");
|
||||
let text = config.redacted_debug();
|
||||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||||
assert!(text.contains(crate::redact::MASK));
|
||||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_falls_back_to_default() {
|
||||
let mut config = ProviderConfig::ollama("llama3");
|
||||
config.base_url = String::new();
|
||||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_states_serialize_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||||
"\"open_ai_compatible\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "provider/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。
|
||||
//!
|
||||
//! ## 为什么不是字符串匹配
|
||||
//!
|
||||
//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` /
|
||||
//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳);
|
||||
//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含
|
||||
//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**;
|
||||
//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable`
|
||||
//! 一度偶发红)。
|
||||
//!
|
||||
//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` /
|
||||
//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。
|
||||
//!
|
||||
//! ## 六档口径
|
||||
//!
|
||||
//! | 档 | 判据(`super::rig::map_error`) | 界面含义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 |
|
||||
//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 |
|
||||
//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 |
|
||||
//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 |
|
||||
//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 |
|
||||
//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) |
|
||||
|
||||
/// 一次 provider 调用的失败分类(见模块文档的六档表)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderFailure {
|
||||
/// 配置不完整:**没有发出任何请求**。
|
||||
NotConfigured,
|
||||
/// 鉴权失败(401 / 403 / 凭据被拒)。
|
||||
Auth,
|
||||
/// 模型不存在(404 / `model_not_found`)。
|
||||
ModelNotFound,
|
||||
/// 端点不可达(传输层失败,没拿到任何回应)。
|
||||
Unreachable,
|
||||
/// 超时(调用方时限或传输层超时)。
|
||||
Timeout,
|
||||
/// 其它(协议 / 解析 / 5xx / 未知)。
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ProviderFailure {
|
||||
/// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotConfigured => "not_configured",
|
||||
Self::Auth => "auth",
|
||||
Self::ModelNotFound => "model_not_found",
|
||||
Self::Unreachable => "unreachable",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。
|
||||
#[must_use]
|
||||
pub const fn sent_a_request(self) -> bool {
|
||||
!matches!(self, Self::NotConfigured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。
|
||||
//!
|
||||
//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路,
|
||||
//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_defaults_match_research() {
|
||||
assert_eq!(
|
||||
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
|
||||
LLAMACPP_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::ollama("llama3").resolved_base_url(),
|
||||
OLLAMA_DEFAULT_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
|
||||
ANTHROPIC_DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_vocabulary_round_trips() {
|
||||
// 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的
|
||||
// `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」
|
||||
// agent 侧读出来是 None(M174 的根因)。
|
||||
for kind in [
|
||||
ProviderKind::OpenAiCompatible,
|
||||
ProviderKind::Anthropic,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::Disabled,
|
||||
] {
|
||||
assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind));
|
||||
}
|
||||
assert_eq!(
|
||||
ProviderKind::from_setting(" ollama "),
|
||||
Some(ProviderKind::Ollama)
|
||||
);
|
||||
assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None);
|
||||
assert_eq!(ProviderKind::from_setting(""), None);
|
||||
// schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。
|
||||
assert_eq!(
|
||||
unlucky_core::setting::keys::AI_PROVIDERS,
|
||||
[
|
||||
ProviderKind::OpenAiCompatible.setting_value(),
|
||||
ProviderKind::Anthropic.setting_value(),
|
||||
ProviderKind::Ollama.setting_value(),
|
||||
ProviderKind::Disabled.setting_value(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_model_base_url_and_key() {
|
||||
assert!(!ProviderConfig::disabled().is_enabled());
|
||||
assert!(ProviderConfig::disabled().validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
|
||||
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
|
||||
// OpenAI 兼容必须显式给端点
|
||||
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(
|
||||
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(ProviderConfig::anthropic("claude").validate().is_err());
|
||||
assert!(ProviderConfig::anthropic("claude")
|
||||
.with_api_key("sk-ant")
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_debug_never_leaks_key() {
|
||||
let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
|
||||
.with_api_key("sk-super-secret");
|
||||
let text = config.redacted_debug();
|
||||
assert!(!text.contains("sk-super-secret"), "{text}");
|
||||
assert!(text.contains(crate::redact::MASK));
|
||||
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("sk-super-secret"), "{debug}");
|
||||
assert!(debug.contains(crate::redact::MASK), "{debug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_falls_back_to_default() {
|
||||
let mut config = ProviderConfig::ollama("llama3");
|
||||
config.base_url = String::new();
|
||||
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_states_serialize_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
|
||||
"\"open_ai_compatible\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
|
||||
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ use std::sync::Arc;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
|
||||
use rig_core::error::ProviderError as RigError;
|
||||
use rig_core::error::{ErrorKind, ProviderError as RigError};
|
||||
use rig_core::message::{
|
||||
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
|
||||
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
|
||||
@@ -64,7 +64,7 @@ use super::chat::{
|
||||
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
|
||||
ProviderFuture,
|
||||
};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
|
||||
use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind};
|
||||
|
||||
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
|
||||
pub const RIG_VERSION_PIN: &str = "=0.45.0";
|
||||
@@ -275,9 +275,63 @@ fn to_reply(
|
||||
})
|
||||
}
|
||||
|
||||
/// rig 错误 → 本方错误(文案面向用户)。
|
||||
/// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` /
|
||||
/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。
|
||||
///
|
||||
/// | rig 侧信号 | 本方分类 |
|
||||
/// |---|---|
|
||||
/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] |
|
||||
/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) |
|
||||
/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] |
|
||||
/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] |
|
||||
/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] |
|
||||
/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] |
|
||||
fn map_error(error: RigError) -> ProviderError {
|
||||
ProviderError::new(error.to_string())
|
||||
let report = error.report();
|
||||
let kind = match report.kind {
|
||||
ErrorKind::Timeout => ProviderFailure::Timeout,
|
||||
ErrorKind::Http => {
|
||||
if transport_timed_out(&error) {
|
||||
ProviderFailure::Timeout
|
||||
} else {
|
||||
ProviderFailure::Unreachable
|
||||
}
|
||||
}
|
||||
ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status {
|
||||
Some(401 | 403) => ProviderFailure::Auth,
|
||||
Some(404) => ProviderFailure::ModelNotFound,
|
||||
_ if matches!(
|
||||
report.code.as_deref(),
|
||||
Some("model_not_found" | "model_not_found_error")
|
||||
) =>
|
||||
{
|
||||
ProviderFailure::ModelNotFound
|
||||
}
|
||||
_ => ProviderFailure::Other,
|
||||
},
|
||||
_ => ProviderFailure::Other,
|
||||
};
|
||||
ProviderError::classified(kind, report.message)
|
||||
}
|
||||
|
||||
/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut`
|
||||
/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box<dyn Error>`)。
|
||||
///
|
||||
/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、
|
||||
/// TLS)都归 `Unreachable`。
|
||||
fn transport_timed_out(error: &RigError) -> bool {
|
||||
let RigError::Http(http_error) = error else {
|
||||
return false;
|
||||
};
|
||||
let mut source = std::error::Error::source(http_error);
|
||||
for _ in 0..8 {
|
||||
let Some(cause) = source else { return false };
|
||||
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
|
||||
return io.kind() == std::io::ErrorKind::TimedOut;
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
|
||||
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
|
||||
super::super::chat::ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_is_rejected_before_touching_the_network() {
|
||||
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
|
||||
panic!("禁用态应在构造阶段被拒");
|
||||
};
|
||||
assert!(err.to_string().contains("禁用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_without_key_is_rejected() {
|
||||
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
|
||||
assert!(RigProvider::new(
|
||||
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_states_build_offline() {
|
||||
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
|
||||
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_maps_messages_and_tools() {
|
||||
let request = ChatRequest {
|
||||
model: "mock".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("把主题换成浅色"),
|
||||
ChatMessage::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![tool("call_1", "set_config")],
|
||||
},
|
||||
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
|
||||
],
|
||||
tools: vec![crate::registry::ToolDefinition {
|
||||
name: "set_config".to_string(),
|
||||
description: "改配置".to_string(),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
}],
|
||||
temperature: Some(0.5),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
let built = build_request(&request).expect("应能构造");
|
||||
assert_eq!(built.chat_history.len(), 4);
|
||||
assert_eq!(built.tools.len(), 1);
|
||||
assert_eq!(built.tools[0].name.as_str(), "set_config");
|
||||
assert_eq!(built.temperature, Some(0.5));
|
||||
assert_eq!(built.max_tokens, Some(512));
|
||||
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
|
||||
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
|
||||
assert!(json.contains("toolresult"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_a_readable_error() {
|
||||
let request = ChatRequest {
|
||||
model: "m".to_string(),
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let err = build_request(&request).unwrap_err();
|
||||
assert!(err.to_string().contains("对话历史为空"));
|
||||
}
|
||||
}
|
||||
#[path = "rig/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。
|
||||
//!
|
||||
//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在
|
||||
//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
|
||||
super::super::chat::ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_is_rejected_before_touching_the_network() {
|
||||
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
|
||||
panic!("禁用态应在构造阶段被拒");
|
||||
};
|
||||
assert!(err.to_string().contains("禁用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_without_key_is_rejected() {
|
||||
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
|
||||
assert!(
|
||||
RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant"))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_states_build_offline() {
|
||||
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
|
||||
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_maps_messages_and_tools() {
|
||||
let request = ChatRequest {
|
||||
model: "mock".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage::system("你是助手"),
|
||||
ChatMessage::user("把主题换成浅色"),
|
||||
ChatMessage::Assistant {
|
||||
text: String::new(),
|
||||
tool_calls: vec![tool("call_1", "set_config")],
|
||||
},
|
||||
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
|
||||
],
|
||||
tools: vec![crate::registry::ToolDefinition {
|
||||
name: "set_config".to_string(),
|
||||
description: "改配置".to_string(),
|
||||
input_schema: serde_json::json!({ "type": "object" }),
|
||||
}],
|
||||
temperature: Some(0.5),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
let built = build_request(&request).expect("应能构造");
|
||||
assert_eq!(built.chat_history.len(), 4);
|
||||
assert_eq!(built.tools.len(), 1);
|
||||
assert_eq!(built.tools[0].name.as_str(), "set_config");
|
||||
assert_eq!(built.temperature, Some(0.5));
|
||||
assert_eq!(built.max_tokens, Some(512));
|
||||
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
|
||||
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
|
||||
assert!(json.contains("toolresult"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_a_readable_error() {
|
||||
let request = ChatRequest {
|
||||
model: "m".to_string(),
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
let err = build_request(&request).unwrap_err();
|
||||
assert!(err.to_string().contains("对话历史为空"));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
[package]
|
||||
name = "unlucky-app-iced"
|
||||
description = "Unlucky-Launcher 桌面壳(iced 0.14 重写版):M3 Expressive 骨架 / 令牌适配 / 截图通道"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# 并存期(D26):Slint 版仍是 `crates/app` 的 `unlucky-launcher`,本 crate 出
|
||||
# `unlucky-launcher-iced`。两个 package 在同一 target 目录产出同名 bin 会冲突,
|
||||
# 所以**翻牌前不许改回 `unlucky-launcher`**(步骤见 SubAgentsTalk/app-iced-骨架与翻牌.md)。
|
||||
[[bin]]
|
||||
name = "unlucky-launcher-iced"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "unlucky_app_iced"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。
|
||||
unlucky-design = { path = "../design", version = "0.2.3" }
|
||||
|
||||
# 平台识别(Termux / $PREFIX 路径)唯一真源:字体注册这类平台适配不再各写一份环境判断。
|
||||
unlucky-core = { path = "../core", version = "0.2.3" }
|
||||
|
||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**——
|
||||
# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。
|
||||
# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2.3" }
|
||||
|
||||
# UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。
|
||||
# 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`),
|
||||
# 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。
|
||||
# `wgpu` 由本 crate 的 `gpu` feature 打开,于是「仅 CPU」体积基线用 `--no-default-features` 就能量到。
|
||||
iced = { version = "=0.14.0", default-features = false, features = [
|
||||
"tiny-skia",
|
||||
"crisp",
|
||||
"advanced",
|
||||
# M93:背景图要真图直出 ⇒ 需要渲染器的 image 支持。用 `image-without-codecs` 而不是
|
||||
# `image` 是刻意的:后者会打开 `image/default` 的全套编解码器;前者的编解码能力由本 crate
|
||||
# 依赖表里那条 `image` 的 features 决定(png / jpeg / bmp),不必把 webp/tiff/exr/gif 拖进来。
|
||||
"image-without-codecs",
|
||||
"svg",
|
||||
"thread-pool",
|
||||
] }
|
||||
|
||||
# 截图落 PNG + 背景图解码(M93):png 是截图通道,jpeg/bmp 是用户真会挑的背景图/壁纸格式
|
||||
# (M73 的壁纸探测不筛扩展名)。仍不开 default-features——那会拖进 avif/ravif/webp/tiff 一整套。
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png",
|
||||
"jpeg",
|
||||
"bmp",
|
||||
] }
|
||||
|
||||
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
|
||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||
# core/agent 不违反 core 零 UI 铁律(core 的依赖树里没有 cli)。零 UI 依赖的 `ull`
|
||||
# 仍在 `crates/cli`,给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.2.3" }
|
||||
|
||||
# M92(D28):国际化地基(语言探测 / 消息目录 / 中文字体策略)。零依赖 crate——它拿不到 iced,
|
||||
# 所以系统字体扫描留在这里(`src/fonts/cjk.rs`),本 crate 只把族名喂给它的纯函数。
|
||||
unlucky-i18n = { path = "../i18n", version = "0.2.3" }
|
||||
|
||||
# M97:判据表第 8 行的取证通道。`iced_wgpu` 在真创建设备时会 `log::info!("Selected: {adapter:#?}")`,
|
||||
# 但进程里从来没人装 logger,于是「看不见」被当成了「没发生」。这里装一个只放行 `iced_wgpu*` /
|
||||
# `wgpu*` 的 logger(见 `src/renderer/log_bridge.rs`),把这行捞出来。
|
||||
# 已是 iced 传递依赖树里的版本(0.4.34),不新增任何传递依赖;MIT OR Apache-2.0,许可白名单内。
|
||||
log = { version = "0.4", features = ["std"] }
|
||||
|
||||
# X11 / Wayland 后端与 Linux 主题检测只在 Linux 上启用:macOS 没有 X11/Wayland(走 Metal +
|
||||
# 系统外观),Windows 走 DX12,这三个 feature 在那边没有意义。同一个包在两处写 features 是
|
||||
# **取并集**(Cargo 的 feature 解析是加法),所以 Linux 上拿到的仍是上面 5 个 + 这 3 个,
|
||||
# Windows / macOS 只拿上面那 5 个(`cargo tree --target` 可复核)。
|
||||
# 诚实边界:这**不**解决「iced 版至今没在 macOS 上编过」这件事——`iced_winit` 已把 `mundy`
|
||||
# 放在 `cfg(target_os = "linux")` 段里,这三个 feature 在非 Linux 上本来就不拉依赖,这里只是
|
||||
# 让本仓库的依赖图按平台自述清楚,不再把 Linux 专属能力写成全平台事实。
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
iced = { version = "=0.14.0", default-features = false, features = [
|
||||
"x11",
|
||||
"wayland",
|
||||
"linux-theme-detection",
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
# 硬件渲染后端(wgpu→Vulkan/DX12)。关闭后只剩 tiny-skia(CPU),用于体积基线与无 GPU 机器。
|
||||
gpu = ["iced/wgpu"]
|
||||
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
|
||||
# ULL_UPDATE_DIR=<root> ULL_UPDATE_TEST_SOURCE=1 \
|
||||
# cargo run -p unlucky-app-iced --features update-test-source
|
||||
# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里
|
||||
# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。
|
||||
# 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。
|
||||
update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# M190:把版本资源写进 PE(Windows「属性 → 详细信息」里的发布者/产品/版权/版本)。
|
||||
# 只在 windows 目标上真跑(`build.rs` 先看 CARGO_CFG_TARGET_OS,其它平台直接返回),
|
||||
# 共享口径在 `packaging/pe_version.rs`(两个 bin 各 include 一次,署名不会漂)。
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
@@ -0,0 +1,14 @@
|
||||
//! `unlucky-launcher-iced` 的 PE 版本资源(M190)。
|
||||
//!
|
||||
//! 与 `crates/cli/build.rs` 同一份共享口径(`packaging/pe_version.rs`):两个产物的
|
||||
//! CompanyName / ProductName / LegalCopyright / 版本号必须逐字相同。
|
||||
//!
|
||||
//! ⚠ 本文件**只**写版本资源,不碰子系统:PE 的 `Subsystem` 由 rustc 的
|
||||
//! `windows_subsystem` 属性决定(本分支上还没有那个属性,是 M196 的事),版本资源与子系统是两件事。
|
||||
//! 判据:`packaging/check_pe_version.py` 会把它读到的 `subsystem` 打出来,加资源前后必须一致。
|
||||
|
||||
include!("../../packaging/pe_version.rs");
|
||||
|
||||
fn main() {
|
||||
emit_pe_version_resource("unlucky-launcher-iced");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||||
//! --screen=instances --width=1180 --height=760 \
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**,
|
||||
//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use unlucky_app_iced::pages::harness::{self, Request};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let request = match Request::parse(std::env::args().skip(1)) {
|
||||
Ok(request) => request,
|
||||
Err(message) => {
|
||||
eprintln!("[cli] {message}");
|
||||
eprintln!(
|
||||
"用法:pages_demo [--screen=instances|download|settings|logs] [--width <px>] \
|
||||
[--height <px>] [--theme=light|dark] [--screenshot-theme=light|dark] \
|
||||
[--screenshot <png>] [--renderer=auto|gpu|cpu] [--software-renderer]"
|
||||
);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match harness::run(request) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(message) => {
|
||||
eprintln!("[run] {message}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! App 侧的外观动作:**下发整窗 α / 写回配置 / 打取证日志**。
|
||||
//!
|
||||
//! 三件事各自只有一条路,且都从这里出去(`update` 只负责把它们按消息串起来):
|
||||
//!
|
||||
//! | 动作 | 唯一落点 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 下发整窗 α | [`App::apply_window_alpha`] → `app::window_alpha::dispatch` | 判据在 `platform`、FFI 在 `platform/win32`,这里只做「取 α → 交给执行侧 → 记日志」 |
|
||||
//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外**三个**只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source` / `window_corner`,它们只能走 schema 指令 |
|
||||
//! | 取证日志 | [`App::log_appearance`] | 一行里同时有请求值、生效值、下发字节与 FFI 结局 |
|
||||
//!
|
||||
//! ## 为什么「写回」要分两条路
|
||||
//!
|
||||
//! `SettingsBridge::persist` 收的是 `AppearancePrefs`(背景图四件 + 面板 α + 玻璃 + 强调色),
|
||||
//! 它的实现是「先读文件当前值,再覆盖我们改的那几个字段」——所以用它写**它覆盖的键**最省事,
|
||||
//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source` /
|
||||
//! `appearance.window_corner` 不在那个结构里(它们是「只读下层键」,见
|
||||
//! `presenter::prefs::AppearanceBase` 的**四个**字段:整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
|
||||
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
|
||||
//! 其中「标题栏三态」目前**没有 UI 写者**(D37 的接线未落地),所以走 `SetSetting` 的是**三个**键。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::pages::appearance::Change;
|
||||
|
||||
use super::state::App;
|
||||
use super::window_alpha::{self, Dispatch};
|
||||
|
||||
impl App {
|
||||
/// 把当前整窗 α 交给执行侧(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)并记一行日志。
|
||||
///
|
||||
/// 没有句柄时**也打日志**(`hwnd=none`):不能让「α 没生效」这件事只体现为「屏幕上没变化」。
|
||||
pub(super) fn apply_window_alpha(&self, context: &str) {
|
||||
let Some(hwnd) = self.hwnd else {
|
||||
self.log_appearance(context, None);
|
||||
return;
|
||||
};
|
||||
let alpha = crate::platform::effective_window_alpha(
|
||||
self.appearance.backend,
|
||||
f64::from(self.appearance.window_opacity),
|
||||
);
|
||||
let dispatch = window_alpha::dispatch(hwnd, alpha);
|
||||
self.log_appearance(context, Some(&dispatch));
|
||||
}
|
||||
|
||||
/// 取证日志:`[appearance] <状态行> layered=… outcome=…`。
|
||||
///
|
||||
/// 数值来自 [`crate::pages::appearance::State::log_line`](请求值 / 生效值 / 下发字节),
|
||||
/// 尾巴来自执行侧。**这一行就是「透明度到底设成了多少」的证据**——RDP 上的观感不可信。
|
||||
pub(super) fn log_appearance(&self, context: &str, dispatch: Option<&Dispatch>) {
|
||||
let mut line = self.appearance.log_line(context);
|
||||
if let Some(dispatch) = dispatch {
|
||||
line.push(' ');
|
||||
line.push_str(&dispatch.describe());
|
||||
} else {
|
||||
line.push_str(" layered=skipped outcome=no-window");
|
||||
}
|
||||
diag!("{line}");
|
||||
}
|
||||
|
||||
/// 设置页改了一项外观。
|
||||
pub(super) fn on_appearance(&mut self, change: Change) {
|
||||
let needs_window = self.appearance.apply(change.clone());
|
||||
|
||||
// 配色来源 / 背景图可能变了 ⇒ `Ui` 可能要改用解析出的方案。
|
||||
self.rebuild_ui();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
|
||||
match change.key() {
|
||||
// 三个只读下层键(整窗 α / 配色来源 / 窗口圆角):走 schema 指令
|
||||
// (`SettingsBridge` 的结构里没有它们;标题栏三态还没有 UI 写者,不在此列)。
|
||||
Some(_)
|
||||
if matches!(
|
||||
change,
|
||||
Change::WindowOpacity(_) | Change::PaletteSource(_) | Change::Corner(_)
|
||||
) =>
|
||||
{
|
||||
if let (Some(key), Some(value)) = (change.key(), change.value()) {
|
||||
self.dispatch(AppCommand::SetSetting {
|
||||
key: key.to_owned(),
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。
|
||||
Some(_) => self.persist_appearance(),
|
||||
None => {}
|
||||
}
|
||||
|
||||
if needs_window {
|
||||
self.apply_window_alpha("change");
|
||||
} else {
|
||||
self.log_appearance("change", None);
|
||||
}
|
||||
// M116:圆角档位变了要**重新判定 + 重新下发**(与整窗 α 是两条独立的管线)。
|
||||
self.apply_window_corner("change");
|
||||
}
|
||||
|
||||
/// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。
|
||||
pub(super) fn reload_appearance(&mut self) {
|
||||
let (prefs, _theme) = self.bridge.load();
|
||||
let base = self.bridge.load_base();
|
||||
self.appearance.reload(&prefs, &base);
|
||||
self.appearance_prefs = prefs;
|
||||
self.rebuild_ui();
|
||||
self.view_dirty = true;
|
||||
self.apply_window_alpha("reload");
|
||||
// M116:外部(CLI / AI)改了 `appearance.window_corner` 也要热生效。
|
||||
self.apply_window_corner("reload");
|
||||
}
|
||||
|
||||
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。
|
||||
fn persist_appearance(&mut self) {
|
||||
let mut prefs = self.appearance_prefs.clone();
|
||||
prefs.bg_image_enabled = self.appearance.bg_enabled;
|
||||
prefs.bg_image = self.appearance.bg_path.clone();
|
||||
prefs.bg_fill = self.appearance.bg_fill;
|
||||
prefs.bg_dim = self.appearance.bg_dim;
|
||||
prefs.panel_alpha = self.appearance.panel_alpha;
|
||||
prefs.glass = self.appearance.glass;
|
||||
|
||||
self.bridge.persist(&prefs, self.theme_mode);
|
||||
self.appearance_prefs = prefs;
|
||||
|
||||
match self.bridge.path() {
|
||||
Some(path) => diag!("[settings] 外观已写回 {}", path.display()),
|
||||
None => diag!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 本进程**实际**用的是哪个渲染后端(D36 的「CPU 档」判据)。
|
||||
//!
|
||||
//! ## 为什么不能直接看 `--renderer` 的请求值
|
||||
//!
|
||||
//! `Options::backend` 是**请求**(默认 `Auto`)。真正落到哪个渲染器由 `probe::apply` 决定,
|
||||
//! 它把结论写进进程环境变量 `ICED_BACKEND`(iced 只认这个)。于是默认参数 + 没有独显的机器上:
|
||||
//! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值,
|
||||
//! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。
|
||||
//!
|
||||
//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 翻译回 [`Backend`]。
|
||||
//! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。
|
||||
//!
|
||||
//! ## M97 之后这条链是准的(以前不是)
|
||||
//!
|
||||
//! 以前 `ICED_BACKEND` 可能是**候选列表**(`wgpu,tiny-skia`),运行期 wgpu 静默回退到
|
||||
//! tiny-skia 时这个变量还停在 wgpu ⇒ 本模块按 **GPU 档**判 ⇒ **半透明在 CPU 渲染器上照开**。
|
||||
//! 也就是说「静默降级」不只让日志说谎,还让 D36 的降级判据一起说谎。
|
||||
//!
|
||||
//! 现在 `ICED_BACKEND` **永远是单项**([`crate::renderer::Kind`]),而且 `app::boot` 的重试
|
||||
//! 在降级重开前会把它改成 `tiny-skia`,于是:**真降级到 CPU 的那一次,面板 α 会正确地强制 1.0**。
|
||||
//!
|
||||
//! ## 已知边界(诚实登记)
|
||||
//!
|
||||
//! `iced` 0.14 没有公开的「当前后端」查询,所以本模块读的仍是**我们请求的那一项**。
|
||||
//! 单项 + 重试([`crate::renderer`] 判据表第 8 行)把「请求 ≠ 实际」的窗口压到只剩一种:
|
||||
//! 重试那次自己也失败——那时候进程会带着具名错误退出,不会带着错的档位继续画。
|
||||
|
||||
use crate::renderer::Kind;
|
||||
use crate::Backend;
|
||||
|
||||
/// `ICED_BACKEND` 的取值 → 后端(纯函数,好测)。
|
||||
///
|
||||
/// 逗号列表按**首项**解释:兼容 M97 之前写下的值(以及外部手设的实验值),
|
||||
/// 但本 crate 不再产出那种值(守门测试见 `probe::tests::apply_writes_exactly_one_backend`)。
|
||||
#[must_use]
|
||||
pub(super) fn parse_kind(env: &str) -> Option<Kind> {
|
||||
match env.trim().split(',').next()?.trim() {
|
||||
"tiny-skia" => Some(Kind::TinySkia),
|
||||
"wgpu" => Some(Kind::Wgpu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ICED_BACKEND` → 实际档位(纯函数,好测)。
|
||||
///
|
||||
/// | `ICED_BACKEND` | 含义(`probe::apply` 的产物) | 结论 |
|
||||
/// |---|---|---|
|
||||
/// | `tiny-skia` | 只有 CPU(`--renderer=cpu` / 未编 wgpu / 探测无硬件 / wgpu 失败后重开) | [`Backend::Cpu`] |
|
||||
/// | `wgpu` | 判据表第 3、6 行:起步就是 GPU(起不来会打印具名错误并重开成 `tiny-skia`) | [`Backend::Gpu`] |
|
||||
/// | 未设 / 别的值 | 不是本 crate 写的(外部手设 / M97 之前的旧进程) | 请求值(不猜) |
|
||||
#[must_use]
|
||||
pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend {
|
||||
env.and_then(parse_kind).map_or(request, Kind::tier)
|
||||
}
|
||||
|
||||
/// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。
|
||||
#[must_use]
|
||||
pub(super) fn effective_backend(request: Backend) -> Backend {
|
||||
backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref())
|
||||
}
|
||||
|
||||
/// 本进程实际生效的**后端名**(`None` = 变量没写 / 不认识)。
|
||||
///
|
||||
/// 与 [`effective_backend`] 的区别:那个给「档位」,这个给「名字」,用于**取证日志**
|
||||
/// (`[shot] … renderer=wgpu`)。两张图分辨不出 GPU/CPU 的时候,靠的就是这一行。
|
||||
#[must_use]
|
||||
pub(super) fn effective_kind() -> Option<Kind> {
|
||||
std::env::var("ICED_BACKEND")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(parse_kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::backend_from_env;
|
||||
use crate::Backend;
|
||||
use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN};
|
||||
|
||||
/// 抓得住:`probe::apply` 现在产出的**每一种单项值**都被翻译成正确的档位,
|
||||
/// 且 M97 之前写下的逗号列表仍按首项解释(向后兼容)。
|
||||
/// 抓不住:运行期重试降级那一刻的时序(那由 `app::boot` 的重试保证,见 `renderer` 判据表第 8 行)。
|
||||
#[test]
|
||||
fn every_iced_backend_value_maps_to_a_tier() {
|
||||
// 本 crate 现在只产单项(`probe::tests::apply_writes_exactly_one_backend` 守这一条)。
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Auto, Some("tiny-skia")),
|
||||
Backend::Cpu,
|
||||
"M97 判据表第 5 行:探测无硬件 ⇒ CPU 档"
|
||||
);
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Auto, Some("wgpu")),
|
||||
Backend::Gpu,
|
||||
"M97 判据表第 6 行:有硬件 ⇒ GPU 档"
|
||||
);
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Cpu, Some("tiny-skia")),
|
||||
Backend::Cpu,
|
||||
"强制 CPU 也是 CPU 档"
|
||||
);
|
||||
// 兼容 M97 之前的逗号列表(外部手设 / 旧进程留下):按首项解释。
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")),
|
||||
Backend::Cpu
|
||||
);
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")),
|
||||
Backend::Gpu
|
||||
);
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")),
|
||||
Backend::Gpu
|
||||
);
|
||||
// 未设 / 空白:保持请求值(不是本 crate 写的,不猜)。
|
||||
assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto);
|
||||
assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto);
|
||||
// 不认识的取值不猜:保留请求值。
|
||||
assert_eq!(
|
||||
backend_from_env(Backend::Auto, Some("metal")),
|
||||
Backend::Auto
|
||||
);
|
||||
}
|
||||
|
||||
/// **复审 r1 P2-2 的守门**:默认档(`Auto`)在无独显机器上必须落进 CPU 档,
|
||||
/// 从而让「面板 α / 玻璃强制 1.0」在默认路径上真的生效。
|
||||
///
|
||||
/// 抓得住:判据走 `effective_backend()` 这条链(谁把 `State::new` 改回只吃请求值,这条会红)。
|
||||
/// 抓不住:整窗 α 在 CPU 档仍要生效(那是另一条判据,见 `alpha` 模块真值表)。
|
||||
#[test]
|
||||
fn auto_with_a_software_renderer_forces_the_panel_opaque() {
|
||||
use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha};
|
||||
|
||||
let backend = backend_from_env(Backend::Auto, Some("tiny-skia"));
|
||||
assert_eq!(backend, Backend::Cpu);
|
||||
assert_eq!(
|
||||
effective_panel_alpha(backend, 0.55),
|
||||
1.0,
|
||||
"D36:CPU 档面板 1.0"
|
||||
);
|
||||
assert!(!effective_glass(backend, true), "D36:CPU 档玻璃强制关");
|
||||
// 但整窗 α 不受限(D36 对 D27 的修订)。
|
||||
assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some());
|
||||
|
||||
// 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。
|
||||
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
|
||||
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
|
||||
assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// **M97 的新守门**:wgpu 失败重开成 `tiny-skia` 之后,档位必须跟着变成 CPU 档。
|
||||
///
|
||||
/// 这是修之前做不到的一条:那时变量停在 `wgpu,tiny-skia`,静默回退到 tiny-skia 之后
|
||||
/// 判据仍然读 `Gpu` ⇒ 半透明在 CPU 渲染器上照开。现在 `app::boot` 重开前会改写这个值,
|
||||
/// 这条测试钉的是「改写成 tiny-skia 之后判据确实翻转」。
|
||||
/// **两个后端取值各自对应哪个档位、面板 α 各是多少。**
|
||||
///
|
||||
/// ⚠️ 这条**不**守「降级后档位翻转」——它只是把两个字面量喂给纯函数,`respawn` 一行都没执行。
|
||||
/// 复审 M97 r1 p2-1 就是靠这一点把 `respawn` 里的 `.env("ICED_BACKEND", …)` 删掉而测试全绿的。
|
||||
/// 真正的翻转由 `app::boot::tests::fallback_env_carries_the_backend_and_the_gate`
|
||||
/// (respawn 到底传了什么)钉住——那条红了才是「翻转坏了」。
|
||||
#[test]
|
||||
fn each_backend_value_maps_to_its_tier_and_alpha() {
|
||||
use crate::pages::appearance::alpha::effective_panel_alpha;
|
||||
|
||||
let gpu = backend_from_env(Backend::Auto, Some("wgpu"));
|
||||
assert_eq!(gpu, Backend::Gpu);
|
||||
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
|
||||
|
||||
let cpu = backend_from_env(Backend::Auto, Some("tiny-skia"));
|
||||
assert_eq!(cpu, Backend::Cpu);
|
||||
assert_eq!(
|
||||
effective_panel_alpha(cpu, 0.55),
|
||||
1.0,
|
||||
"D36:CPU 档面板 α 强制 1.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! 启动编排:**探测 → 字体 → 打印诊断 → 起 iced**(四步,别把它撑回一坨)。
|
||||
//!
|
||||
//! ## 三条纪律(每条都对应一次返工)
|
||||
//!
|
||||
//! 1. **后端起不来时的两条路策略不同**(判据表第 8/9 行):交互启动换进程重开、取证启动
|
||||
//! (`--screenshot`)拒绝出图 —— 实现与测试都在 [`fallback`]。
|
||||
//! 2. **窗口设置只有一个产地**([`diagnostics::window_settings`]),`[window]` / `[caps]` 两行日志
|
||||
//! 从**同一个** `&window::Settings` 取字段。M98 r1 p2-1 的教训:把 `decorations=false` 写进
|
||||
//! 格式化串,等于让日志与真实行为可以各走各的(踩坑记录 U1)。
|
||||
//! 3. **没测过的就写 `skipped`**,不许拿 `0ms` 冒充实测([`diagnostics::report_font`] 的 `probe`)。
|
||||
//!
|
||||
//! 为什么重开必须是**换进程**:Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::fonts;
|
||||
use crate::probe;
|
||||
use crate::renderer::{self, acceptance, Decision};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::message::Message;
|
||||
use super::state::{App, STARTED};
|
||||
use super::{MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod diagnostics;
|
||||
mod fallback;
|
||||
mod termux_gpu;
|
||||
mod termux_window;
|
||||
|
||||
use fallback::{on_failure, respawn, OnFailure};
|
||||
|
||||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||
pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let _ = STARTED.set(Instant::now());
|
||||
let boot_started = Instant::now();
|
||||
|
||||
// ① 探测(判据取在哪、日志在 `report_boot` 里统一打)
|
||||
// Termux + glibc 构建:装了我们自编的 turnip ICD 就先生效,auto 也能选到 GPU。
|
||||
termux_gpu::enable();
|
||||
let boot = boot_probe(&options);
|
||||
let decision = boot.decision;
|
||||
diagnostics::report_boot(&options, &boot.probe, &decision);
|
||||
|
||||
// ② 字体(`fonts` 自己打两行既有日志;新加的那行在 `report_font` 里)
|
||||
let font_started = Instant::now();
|
||||
let resolved = fonts::resolve();
|
||||
let font_ms = font_started.elapsed();
|
||||
diag!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
|
||||
diag!("{}", resolved.describe());
|
||||
if resolved.has_synthetic() {
|
||||
diag!(
|
||||
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
|
||||
);
|
||||
}
|
||||
let families = resolved.families();
|
||||
|
||||
// ③ 打印(主题来源 + 字体回退链 + 启动耗时)
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
|
||||
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||||
diag!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
}
|
||||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
diagnostics::report_theme(&options, flavor);
|
||||
diagnostics::report_font(
|
||||
resolved.platform,
|
||||
boot.probe_ms,
|
||||
font_ms,
|
||||
boot_started.elapsed(),
|
||||
);
|
||||
|
||||
// ④ 起 iced(失败按判据表第 8/9 行分流;具名原因在分流之前就打到同一个 stderr 上了)
|
||||
let launch_started = Instant::now();
|
||||
let result = match launch(&options, families, flavor, decision.primary) {
|
||||
Err(iced::Error::GraphicsCreationFailed(error)) => {
|
||||
renderer::report_failure(decision.primary, &error);
|
||||
match on_failure(decision, options.screenshot.as_ref()) {
|
||||
Some(OnFailure::Respawn(fallback)) => {
|
||||
respawn(decision.primary, fallback, &error.to_string())
|
||||
}
|
||||
Some(OnFailure::Refuse) => {
|
||||
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
|
||||
diag!(
|
||||
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
|
||||
options
|
||||
.screenshot
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |path| path.display().to_string())
|
||||
);
|
||||
diag!(
|
||||
"[gpu] 想用 CPU 出图请显式加 --renderer=cpu / --software-renderer(CI 就是这么跑的)。"
|
||||
);
|
||||
Err(iced::Error::GraphicsCreationFailed(error))
|
||||
}
|
||||
None => Err(iced::Error::GraphicsCreationFailed(error)),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let launch_elapsed = launch_started.elapsed();
|
||||
|
||||
diagnostics::report_outcome(
|
||||
&decision,
|
||||
result.as_ref().err().map(|error| {
|
||||
renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string())
|
||||
}),
|
||||
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
|
||||
if options.screenshot.is_some() {
|
||||
"screenshot"
|
||||
} else {
|
||||
"interactive"
|
||||
},
|
||||
launch_elapsed,
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 按一次后端启动 iced。`backend` 决定这次写进 `ICED_BACKEND` 的唯一一项。
|
||||
fn launch(
|
||||
options: &Options,
|
||||
families: Families,
|
||||
flavor: SchemeFlavor,
|
||||
backend: renderer::Kind,
|
||||
) -> iced::Result {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
|
||||
let boot_options = options.clone();
|
||||
|
||||
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
|
||||
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
|
||||
// Termux:没有环境覆盖时,默认尺寸按 X 屏幕等比自适应(横屏优先),
|
||||
// 失败就退回 1180×760 的验收尺寸。
|
||||
let (default_size, default_source) = termux_window::default_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
)
|
||||
.unwrap_or(((WINDOW_SIZE.width, WINDOW_SIZE.height), "default"));
|
||||
let (size, size_source) =
|
||||
acceptance::window_size(default_size, (MIN_SIZE.width, MIN_SIZE.height));
|
||||
let size_source = if size_source == "default" {
|
||||
default_source
|
||||
} else {
|
||||
size_source
|
||||
};
|
||||
let settings = diagnostics::window_settings(size);
|
||||
// 两行日志与开窗用的是**同一个** settings(见模块文档纪律 2)
|
||||
diagnostics::report_window(&settings, options.scale_factor, size_source);
|
||||
|
||||
let application = iced::application(
|
||||
move || {
|
||||
(
|
||||
App::new(boot_options.clone(), families, flavor),
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
App::update,
|
||||
App::view,
|
||||
)
|
||||
.title(App::title)
|
||||
.theme(App::theme)
|
||||
.subscription(App::subscription)
|
||||
.default_font(default_font(families))
|
||||
.window(settings);
|
||||
|
||||
match options.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + **探测耗时(没测就是 `None`)**。
|
||||
///
|
||||
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
|
||||
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
|
||||
struct Boot {
|
||||
decision: Decision,
|
||||
probe: probe::Probe,
|
||||
probe_ms: Option<Duration>,
|
||||
}
|
||||
|
||||
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
|
||||
fn boot_probe(options: &Options) -> Boot {
|
||||
// 「上一步失败后重开」的那一次:别再探(会得到同一个结论,然后无限重开)。兜底必须是 None
|
||||
// ——由 `decision::fallback_round` 单点保证。
|
||||
if let Some(kind) = renderer::forced_fallback() {
|
||||
let decision = renderer::fallback_round(kind);
|
||||
std::env::set_var("ICED_BACKEND", kind.env_value());
|
||||
diag!(
|
||||
"[probe] 本次是降级重开({}={}):跳过探测 -> ICED_BACKEND={}",
|
||||
renderer::FALLBACK_ENV,
|
||||
kind.env_value(),
|
||||
kind.env_value()
|
||||
);
|
||||
diag!("[probe] {}", decision.describe());
|
||||
// 验收要 `[env]` 里的真实适配器:这一轮**也枚举一次,并如实计时**(没计时就 `None`
|
||||
// ⇒ 日志写 `probe=skipped`,不写 0ms)。
|
||||
let (probe, probe_ms) = if acceptance::enabled() {
|
||||
let started = Instant::now();
|
||||
let probe = probe::Probe::run();
|
||||
(probe, Some(started.elapsed()))
|
||||
} else {
|
||||
(probe::Probe::default(), None)
|
||||
};
|
||||
return Boot {
|
||||
decision,
|
||||
probe,
|
||||
probe_ms,
|
||||
};
|
||||
}
|
||||
|
||||
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
|
||||
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
|
||||
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
|
||||
let probe_started = Instant::now();
|
||||
let probe = if needs_probe {
|
||||
probe::Probe::run()
|
||||
} else {
|
||||
probe::Probe::default()
|
||||
};
|
||||
let probe_ms = needs_probe.then(|| probe_started.elapsed());
|
||||
|
||||
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
|
||||
let preflight = renderer::preflight::run_if_requested();
|
||||
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
|
||||
|
||||
if cfg!(feature = "gpu") {
|
||||
match probe_ms {
|
||||
Some(elapsed) => diag!(
|
||||
"[probe] 探测耗时 {:.0} ms(wgpu 实例 + 全后端适配器枚举)",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
),
|
||||
None => diag!(
|
||||
"[probe] 探测耗时 跳过(--renderer={} 与硬件无关,未枚举)",
|
||||
options.backend.name()
|
||||
),
|
||||
}
|
||||
}
|
||||
Boot {
|
||||
decision,
|
||||
probe,
|
||||
probe_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认字体(正文档解析出来的族)。
|
||||
fn default_font(families: Families) -> iced::Font {
|
||||
iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "boot/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 启动阶段的**验收诊断编排**:把 `app/boot.rs` 的 `run()` 收成「探测 → 字体 → 打印 → 起 iced」。
|
||||
//!
|
||||
//! ## 为什么单独一个文件(M98 r1 p2-1 的根因)
|
||||
//!
|
||||
//! 第一版把 `[window] requested … resizable=true decorations=false transparent=false` 直接写在
|
||||
//! 格式化串里,而 `boot.rs` 给窗口的 `settings` 是另一处常量 —— 两者**没有任何绑定**。定向变异
|
||||
//! 把 `settings.decorations` 改成 `true`,371 个测试全绿,而验收包里 `[caps] decorations=true` 与
|
||||
//! `[window] decorations=false` 会互相矛盾(踩坑记录 U1:常量冒充观测结果)。
|
||||
//!
|
||||
//! 现在:窗口设置由 [`window_settings`] **一处**产生,日志由 [`report_window`] 从**同一个
|
||||
//! `&window::Settings`** 取字段。钉死它的守门测试在 `boot/tests.rs`(`window_settings_*`)。
|
||||
//!
|
||||
//! 另一件事:所有「跑没跑」都要能如实说。降级重开那一轮没计时就写 `probe=skipped`,
|
||||
//! 不许拿 `0ms` 冒充一次实测(M98 r1 p3-1)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use iced::window;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform as FontPlatform;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::probe::Probe;
|
||||
use crate::renderer::{acceptance, Decision};
|
||||
|
||||
use super::MIN_SIZE;
|
||||
use crate::app::APP_TITLE;
|
||||
|
||||
/// 产品窗口设置(**唯一产地**:`launch()` 用它开窗,`[window]` / `[caps]` 从它取字段)。
|
||||
///
|
||||
/// `ULL_WINDOW_SIZE` 的覆盖在 `boot.rs` 解析成 `size` 后从这里进来 —— 日志与真窗口永远同源。
|
||||
pub(super) fn window_settings(size: (f32, f32)) -> window::Settings {
|
||||
window::Settings {
|
||||
size: iced::Size::new(size.0, size.1),
|
||||
min_size: Some(MIN_SIZE),
|
||||
// 自绘三键(M93):窗口**不许**带系统装饰,否则 macOS 上会出现原生标题栏 + 自绘三键两套。
|
||||
//
|
||||
// M182:这一行**同时**是「macOS 四角是直角」的原因——winit 见到 `decorations: false`
|
||||
// 会给出 `NSWindowStyleMask::Borderless`(没有 `Titled`),而窗口服务器只给 titled 窗
|
||||
// 做圆角裁剪。链条与「要不要迁移原生标题栏」的口径写在 `platform::macos` 的模块文档里,
|
||||
// 改动这一行之前先读那一页(迁移必须连同撤掉自绘三键一起做,否则两套控件打架)。
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
|
||||
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
|
||||
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
|
||||
transparent: false,
|
||||
..window::Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `[window] requested` + `[caps]` 两行,**都从同一个 `settings` 取**(不给手抄字面量的机会)。
|
||||
pub(super) fn report_window(settings: &window::Settings, scale: Option<f32>, size_source: &str) {
|
||||
let facts = acceptance::WindowFacts::from_settings(settings);
|
||||
acceptance::report_window(&facts, scale, size_source, Some(APP_TITLE));
|
||||
acceptance::report_caps(
|
||||
std::env::current_exe().ok().as_deref(),
|
||||
facts.transparent,
|
||||
facts.resizable,
|
||||
facts.decorations,
|
||||
);
|
||||
}
|
||||
|
||||
/// 第一段:`[acceptance] on` + `[backend] plan` +(验收模式)`[env]` / `[i18n]`。
|
||||
pub(super) fn report_boot(options: &Options, probe: &Probe, decision: &Decision) {
|
||||
if acceptance::enabled() {
|
||||
acceptance::announce();
|
||||
}
|
||||
acceptance::report_backend_plan(decision, options.backend);
|
||||
if acceptance::enabled() {
|
||||
acceptance::report_startup(probe, options.scale_factor, options.screenshot.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二段:`[font] chain` + `[perf] boot`(都要 `ULL_ACCEPTANCE=1`)。
|
||||
///
|
||||
/// `probe = None` = 这一轮**没有探测**(既不是 `auto` 也没开验收)⇒ 打 `probe=skipped`。
|
||||
pub(super) fn report_font(
|
||||
platform: FontPlatform,
|
||||
probe: Option<Duration>,
|
||||
font: Duration,
|
||||
to_run: Duration,
|
||||
) {
|
||||
if acceptance::enabled() {
|
||||
acceptance::report_font_chain(platform);
|
||||
acceptance::report_perf_boot(probe, font, to_run);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三段:`[theme]`(恒开:它解释的是「这次主题凭什么是这个」)。
|
||||
pub(super) fn report_theme(options: &Options, flavor: SchemeFlavor) {
|
||||
let (source, follows) = acceptance::theme_source(
|
||||
options.screenshot_theme.is_some(),
|
||||
options.theme.is_some(),
|
||||
options.screenshot.is_some(),
|
||||
);
|
||||
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
|
||||
acceptance::report_theme(source, explicit, follows);
|
||||
}
|
||||
|
||||
/// 第四段:`[backend] final`(恒开)+ `[perf] session`(验收模式)。
|
||||
///
|
||||
/// `error` = 具名失败原因(`None` = 起来了);`via` 见 `renderer::acceptance::failure_via`。
|
||||
/// `refuse` / `none` 两条路**什么都没跑起来** ⇒ `final=none`;只有 `respawn` 才有「换到哪个
|
||||
/// 后端」可言(那一格是兜底名,子进程自己还会再打一遍自己的 plan/final)。
|
||||
pub(super) fn report_outcome(
|
||||
decision: &Decision,
|
||||
error: Option<String>,
|
||||
via: &str,
|
||||
mode: &str,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
acceptance::report_outcome(decision, error.as_deref(), via, mode, elapsed);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 起步后端失败后的**兜底/重开**一簇:判据、进程环境交接、`respawn`、退出码透传。
|
||||
//!
|
||||
//! 从 `app/boot.rs` 拆出来的理由:这一簇**与启动编排零耦合**,但已有 5 条测试,而且它是
|
||||
//! M97 判据表第 8/9 行的落点。`boot.rs` 那边只负责「什么时候调它」。
|
||||
//!
|
||||
//! ## 为什么必须换进程(别改回进程内重试)
|
||||
//!
|
||||
//! Windows 实测本进程里再 `run()` 一次直接 panic
|
||||
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`),winit 一个进程
|
||||
//! 只能建一次事件循环。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::renderer::Decision;
|
||||
use crate::renderer::{self, acceptance, Kind};
|
||||
|
||||
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum OnFailure {
|
||||
/// 交互启动:安全网优先,换进程用这个后端重开。
|
||||
Respawn(Kind),
|
||||
/// 取证启动:红着退出,**不出图**。
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// 纯判据:起步后端失败时走哪条路。
|
||||
///
|
||||
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
|
||||
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
|
||||
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
|
||||
pub(super) fn on_failure(
|
||||
decision: Decision,
|
||||
evidence: Option<&std::path::PathBuf>,
|
||||
) -> Option<OnFailure> {
|
||||
let fallback = decision.fallback?;
|
||||
Some(match evidence {
|
||||
Some(_) => OnFailure::Refuse,
|
||||
None => OnFailure::Respawn(fallback),
|
||||
})
|
||||
}
|
||||
|
||||
/// 降级重开要传给子进程的环境变量(**两条缺一不可**)。
|
||||
///
|
||||
/// ① `ICED_BACKEND`:让子进程直接用 `fallback` 跑。
|
||||
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
|
||||
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
|
||||
///
|
||||
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
|
||||
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
|
||||
pub(super) fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
|
||||
[
|
||||
("ICED_BACKEND", fallback.env_value()),
|
||||
(renderer::FALLBACK_ENV, fallback.env_value()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
|
||||
///
|
||||
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
|
||||
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
|
||||
pub(super) fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|err| {
|
||||
diag!("[gpu] 降级重开失败:拿不到自身路径({err})");
|
||||
std::process::exit(exit_code(None));
|
||||
});
|
||||
diag!(
|
||||
"[gpu] 降级 → {}({} 已失败,原因见上两行;换一个进程重开:winit 不允许同进程建两次事件循环)。\
|
||||
下一次的日志从 `[probe] 本次是降级重开` 开始。",
|
||||
fallback.name(),
|
||||
failed.name()
|
||||
);
|
||||
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
|
||||
// 这里 `final=` 装的是**兜底**(进程即将带着它重开);`via=respawn` 说明它是换进程去的。
|
||||
acceptance::report_backend_failed(failed, Some(fallback), Some(fallback), "respawn", error);
|
||||
|
||||
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
|
||||
|
||||
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
|
||||
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
|
||||
let code = match status {
|
||||
Ok(status) => exit_code(status.code()),
|
||||
Err(err) => {
|
||||
diag!("[gpu] 降级重开失败:{err}");
|
||||
exit_code(None)
|
||||
}
|
||||
};
|
||||
std::process::exit(code)
|
||||
}
|
||||
|
||||
/// 子进程退出码 → 本进程退出码。
|
||||
///
|
||||
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
|
||||
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
|
||||
pub(super) fn exit_code(code: Option<i32>) -> i32 {
|
||||
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
|
||||
const RESPAWN_FAILED: i32 = 2;
|
||||
code.unwrap_or(RESPAWN_FAILED)
|
||||
}
|
||||
|
||||
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
|
||||
///
|
||||
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令
|
||||
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。
|
||||
///
|
||||
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
|
||||
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
|
||||
pub(super) fn fallback_command(
|
||||
program: &std::path::Path,
|
||||
args: impl IntoIterator<Item = std::ffi::OsString>,
|
||||
fallback: Kind,
|
||||
) -> std::process::Command {
|
||||
// 子进程就是本 GUI exe 自己(PE Subsystem=2,本来就不会分到控制台),所以这一处今天闪不了;
|
||||
// 走 `quiet` 是为了让「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住——将来
|
||||
// 某天把降级重开换成控制台辅助程序时,不会从这里漏出一块黑框(M217)。
|
||||
let mut command = unlucky_core::platform::child::quiet(program);
|
||||
command.args(args).envs(fallback_env(fallback));
|
||||
command
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
command
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Termux 上自动启用随附的 Vulkan ICD(glibc 侧 turnip,`ull_freedreno.json`)。
|
||||
//!
|
||||
//! 背景:Termux 软件源里的 glibc Mesa(24.2.6)不认识 Adreno 840,硬件 GPU 需要用
|
||||
//! `packaging/docs/termux-glibc-build.md` §5 里自编的 Mesa 26(KGSL)。驱动装在
|
||||
//! `$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json` 时,这里在探测之前把它设进
|
||||
//! `VK_ICD_FILENAMES` / `VK_DRIVER_FILES`,让 `auto` 也能选到 GPU。
|
||||
//!
|
||||
//! 只在 **glibc 构建**(`target_os = "linux"`)上生效:Termux 原生(bionic)构建的
|
||||
//! `target_os = "android"`,glibc 驱动对它没有意义。用户显式设了这两个变量时不覆盖。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
/// 随附 ICD 的相对路径(相对 `$PREFIX`)。
|
||||
const ICD_REL: &str = "glibc/share/vulkan/icd.d/ull_freedreno.json";
|
||||
|
||||
/// 在 GPU 探测之前调用:装了就启用,没装就什么都不做。
|
||||
pub(super) fn enable() {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return;
|
||||
}
|
||||
if std::env::var_os("VK_ICD_FILENAMES").is_some()
|
||||
|| std::env::var_os("VK_DRIVER_FILES").is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(prefix) = termux::prefix(&ProcessEnv) else {
|
||||
return;
|
||||
};
|
||||
let icd = icd_path(&prefix);
|
||||
if icd.is_file() {
|
||||
std::env::set_var("VK_ICD_FILENAMES", &icd);
|
||||
std::env::set_var("VK_DRIVER_FILES", &icd);
|
||||
}
|
||||
}
|
||||
|
||||
/// `$PREFIX` 下随附 ICD 的路径(纯函数,测试用)。
|
||||
fn icd_path(prefix: &Path) -> PathBuf {
|
||||
prefix.join(ICD_REL)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::icd_path;
|
||||
|
||||
#[test]
|
||||
fn icd_path_is_under_the_glibc_prefix() {
|
||||
let path = icd_path(std::path::Path::new("/data/data/com.termux/files/usr"));
|
||||
assert_eq!(
|
||||
path,
|
||||
std::path::PathBuf::from(
|
||||
"/data/data/com.termux/files/usr/glibc/share/vulkan/icd.d/ull_freedreno.json"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Termux / termux-x11 下的窗口尺寸自适应(横屏优先)。
|
||||
//!
|
||||
//! 验收尺寸(1180×760)是 D30 的硬要求,不能改;但手机显示器可能横屏远大于它
|
||||
//! (例如 2066×1272)或竖屏略小。这里只在**运行时**把「默认尺寸」换成按当前 X
|
||||
//! 屏幕算出的最大等比矩形;`ULL_WINDOW_SIZE` 覆盖仍然优先(见 `acceptance::window_size`)。
|
||||
//!
|
||||
//! 只在 Termux + 有 `DISPLAY` 时探测;`xdpyinfo` 缺失/解析失败一律回默认,不影响其它平台。
|
||||
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::platform::termux;
|
||||
|
||||
/// 屏幕四周留白(像素)。
|
||||
const MARGIN: f32 = 32.0;
|
||||
/// 最大放大倍数:大屏上也不把窗口撑成巨屏。
|
||||
const MAX_SCALE: f32 = 2.0;
|
||||
/// `xdpyinfo` 里尺寸那一行的前缀。
|
||||
const DIMENSIONS_PREFIX: &str = "dimensions:";
|
||||
|
||||
/// Termux 上的默认窗口尺寸:按 X 屏幕等比放大/缩小,失败返回 `None`(用调用方默认)。
|
||||
///
|
||||
/// `default` / `min` 由调用方传入([`super::WINDOW_SIZE`] / [`super::MIN_SIZE`]),
|
||||
/// 这里不复制常量。
|
||||
#[must_use]
|
||||
pub(super) fn default_size(
|
||||
default: (f32, f32),
|
||||
min: (f32, f32),
|
||||
) -> Option<((f32, f32), &'static str)> {
|
||||
if !termux::is_termux(&ProcessEnv) || std::env::var_os("DISPLAY").is_none() {
|
||||
return None;
|
||||
}
|
||||
let screen = x_screen_size()?;
|
||||
Some((fit_screen(default, min, screen), "termux-screen"))
|
||||
}
|
||||
|
||||
/// 在 `screen` 里放一个保持 `default` 宽高比的最大矩形(保留留白,不突破 `min`)。
|
||||
#[must_use]
|
||||
pub(super) fn fit_screen(default: (f32, f32), min: (f32, f32), screen: (f32, f32)) -> (f32, f32) {
|
||||
if screen.0 <= 0.0 || screen.1 <= 0.0 || default.0 <= 0.0 || default.1 <= 0.0 {
|
||||
return default;
|
||||
}
|
||||
let available = (
|
||||
(screen.0 - MARGIN * 2.0).max(1.0),
|
||||
(screen.1 - MARGIN * 2.0).max(1.0),
|
||||
);
|
||||
let scale = (available.0 / default.0)
|
||||
.min(available.1 / default.1)
|
||||
.min(MAX_SCALE);
|
||||
let width = (default.0 * scale).max(min.0).min(screen.0);
|
||||
let height = (default.1 * scale).max(min.1).min(screen.1);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
/// 当前 X 屏幕尺寸(`xdpyinfo` 的 `dimensions: WxH pixels`)。
|
||||
fn x_screen_size() -> Option<(f32, f32)> {
|
||||
// 非 Windows 上 `quiet` 是原样返回(POSIX 没有「spawn 就新开一个终端」这回事);走它是为了
|
||||
// 不在整棵 `crates/` 的子进程守卫里开一个「Termux 专用」例外(M217)。
|
||||
let output = unlucky_core::platform::child::quiet("xdpyinfo")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let line = text
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(DIMENSIONS_PREFIX))?;
|
||||
let value = line.split_whitespace().nth(1)?;
|
||||
let (width, height) = value.split_once('x')?;
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fit_screen;
|
||||
|
||||
const DEFAULT: (f32, f32) = (1180.0, 760.0);
|
||||
const MIN: (f32, f32) = (880.0, 560.0);
|
||||
|
||||
fn close(actual: (f32, f32), expected: (f32, f32)) {
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.5 && (actual.1 - expected.1).abs() < 0.5,
|
||||
"expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_screen_scales_up_keeping_aspect() {
|
||||
// 2066×1272:留白后 2002×1208,高度是瓶颈 → 1875.6×1208。
|
||||
close(
|
||||
fit_screen(DEFAULT, MIN, (2066.0, 1272.0)),
|
||||
(1875.58, 1208.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_screen_still_fits_width() {
|
||||
// 1272×2066:宽度是瓶颈 → 1208×778。
|
||||
close(fit_screen(DEFAULT, MIN, (1272.0, 2066.0)), (1208.0, 778.03));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_screen_is_capped_at_two_times() {
|
||||
close(fit_screen(DEFAULT, MIN, (4000.0, 3000.0)), (2360.0, 1520.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_screen_never_goes_below_min_or_overflow() {
|
||||
let (width, height) = fit_screen(DEFAULT, MIN, (800.0, 600.0));
|
||||
assert_eq!(width, 800.0, "宽度受屏幕限制");
|
||||
assert_eq!(height, 560.0, "高度不小于最小尺寸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_screen_falls_back_to_default() {
|
||||
assert_eq!(fit_screen(DEFAULT, MIN, (0.0, 0.0)), DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `app/boot.rs` 的单元测试 + **变异验证说明**(M97 r1)。
|
||||
//!
|
||||
//! 放独立文件(同 `pages/harness/tests.rs`)是为了让 `boot.rs` 留在 400 行内。
|
||||
|
||||
//! ## 变异验证怎么做(M97 r1 的教训,改测试前先读这段)
|
||||
//!
|
||||
//! 这批测试的价值不是「跑绿了」,而是「把实现改坏会红」。做变异时**只在目标函数体内替换**,
|
||||
//! 并且把测试里的同一字面量排除在外:
|
||||
//!
|
||||
//! ```text
|
||||
//! # // 示意(真实脚本是一次性的,没入库)
|
||||
//! def in_body(path, marker, changes):
|
||||
//! """按函数体范围替换。**不能**全局 sed:那会连测试期望里的同一字面量一起改掉,
|
||||
//! 于是实现和断言被换成同一个错值 ⇒ 测试照样绿 ⇒ 验证的是变异脚本自己。"""
|
||||
//! ```
|
||||
//!
|
||||
//! M97 r1 第一次跑变异时,M1/M2/M3 三个变异「照样绿」——不是测试弱,是脚本用全局 `sed`
|
||||
//! 把断言一起改了。这类假绿最阴:变异验证本来是我们判断「测试够不够」的终极手段,
|
||||
//! 手段本身假绿时,它还会附赠「已验证」的强信心。
|
||||
|
||||
use super::fallback::*;
|
||||
use super::*;
|
||||
|
||||
use crate::renderer::acceptance::WindowFacts;
|
||||
use crate::renderer::decision;
|
||||
use crate::renderer::Kind;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::Path;
|
||||
|
||||
fn probe(device_type: &'static str) -> crate::Probe {
|
||||
crate::Probe {
|
||||
adapters: vec![crate::probe::AdapterSummary {
|
||||
name: "adapter".to_owned(),
|
||||
device_type,
|
||||
}],
|
||||
hardware: !matches!(device_type, "Cpu" | "Other"),
|
||||
}
|
||||
}
|
||||
|
||||
/// **降级重开到底传了什么**(M97 r1 p2-1 的守门)。
|
||||
///
|
||||
/// 抓得住:`fallback_env` 少写/写错任一条——两条都是静默失效型:
|
||||
/// 漏 `ICED_BACKEND` ⇒ 子进程又去试 wgpu(白开一次窗);漏 `ULL_GPU_FALLBACK` ⇒ **无限重开**。
|
||||
/// 抓不住:`Command` 本身没被建起来(那要真起进程,见 §「Windows 实测」)。
|
||||
#[test]
|
||||
fn fallback_env_carries_the_backend_and_the_gate() {
|
||||
for fallback in [Kind::TinySkia, Kind::Wgpu] {
|
||||
let env = fallback_env(fallback);
|
||||
assert_eq!(
|
||||
env.len(),
|
||||
2,
|
||||
"就这两条,多一条少一条都要在这里改文档:{env:?}"
|
||||
);
|
||||
assert!(
|
||||
env.contains(&("ICED_BACKEND", fallback.env_value())),
|
||||
"子进程必须直接用 {fallback:?} 跑,实际 {env:?}"
|
||||
);
|
||||
assert!(
|
||||
env.contains(&(renderer::FALLBACK_ENV, fallback.env_value())),
|
||||
"缺防无限重开的闸门 {FALLBACK_ENV:?},实际 {env:?}",
|
||||
FALLBACK_ENV = renderer::FALLBACK_ENV
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 闸门的另一半:子进程那一轮的决策**不能再有兜底**,否则失败后继续 respawn ⇒ 无限递归。
|
||||
///
|
||||
/// 抓得住:`fallback_round` 的 `fallback` 被写成 `Some(..)`(自杀式循环)。
|
||||
/// 抓不住:`forced_fallback()` 没被 `boot_probe` 调用(那是环境变量的读,见下一条的说明)。
|
||||
#[test]
|
||||
fn fallback_round_never_degrades_again() {
|
||||
for kind in [Kind::TinySkia, Kind::Wgpu] {
|
||||
let decision = decision::fallback_round(kind);
|
||||
assert_eq!(decision.primary, kind);
|
||||
assert_eq!(
|
||||
decision.fallback, None,
|
||||
"降级重开那一次不许再有兜底,否则会无限递归重开"
|
||||
);
|
||||
assert_eq!(decision.reason, "fallback-after-failure");
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据表第 8 行 vs 第 9 行:**交互降级 / 取证拒绝**,两条路的策略不同。
|
||||
///
|
||||
/// 抓得住:`--screenshot` 分支被改回「也降级」(那正是 M97 要杀的「取证工具自己换后端」)。
|
||||
/// 抓不住:`screenshot` 传进来的时机(那是 `run()` 的接线,见 Windows 实测记录)。
|
||||
#[test]
|
||||
fn evidence_start_refuses_instead_of_degrading() {
|
||||
let auto = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), true);
|
||||
let evidence = std::path::PathBuf::from("out.png");
|
||||
|
||||
assert_eq!(
|
||||
on_failure(auto, Some(&evidence)),
|
||||
Some(OnFailure::Refuse),
|
||||
"取证启动 + wgpu 起不来 ⇒ 拒绝出图"
|
||||
);
|
||||
assert_eq!(
|
||||
on_failure(auto, None),
|
||||
Some(OnFailure::Respawn(Kind::TinySkia)),
|
||||
"交互启动 + wgpu 起不来 ⇒ 换进程降级"
|
||||
);
|
||||
}
|
||||
|
||||
/// **构造侧**:argv 原样透传、两组 env 都在、program 就是传进来的那个。
|
||||
///
|
||||
/// 抓得住:重开时丢掉用户参数(`--screenshot` / `--screen` / `--data-dir` 全没了,
|
||||
/// 子进程会按默认参数跑,那是最难查的一种「重开后行为变了」)、env 少传(M97 r1 的 p2-1)。
|
||||
/// 抓不住:stdio 继承(`Command` 没有 stdio getter,只能真跑一次,见 `fallback_command` 的注释)。
|
||||
#[test]
|
||||
fn fallback_command_passes_program_argv_and_envs() {
|
||||
let args = ["--renderer=auto", "--screenshot", "out.png"];
|
||||
let command = fallback_command(
|
||||
Path::new("unlucky-launcher-iced"),
|
||||
args.iter().map(OsString::from),
|
||||
Kind::TinySkia,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
command.get_program(),
|
||||
OsStr::new("unlucky-launcher-iced"),
|
||||
"program 必须是传进来的那个(生产里是 current_exe)"
|
||||
);
|
||||
assert_eq!(
|
||||
command.get_args().collect::<Vec<_>>(),
|
||||
args.iter().map(OsStr::new).collect::<Vec<_>>(),
|
||||
"argv 必须原样透传,一条不漏、顺序不变"
|
||||
);
|
||||
let envs: Vec<_> = command.get_envs().collect();
|
||||
for (key, value) in fallback_env(Kind::TinySkia) {
|
||||
assert!(
|
||||
envs.contains(&(OsStr::new(key), Some(OsStr::new(value)))),
|
||||
"重开命令缺 {key}={value},实际 {envs:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **执行侧**:子进程退出 7 → 父进程拿到 7(不是 0、不是自己造的值)。
|
||||
///
|
||||
/// 抓得住:`exit_code` 被改成恒 0 / 恒 1 / 写成 `status.success().into()`。
|
||||
/// 抓不住:生产里 `current_exe()` 那个 program(测试用假命令,见 `fallback_command` 注释)。
|
||||
#[test]
|
||||
fn fallback_command_really_passes_the_child_exit_code() {
|
||||
#[cfg(windows)]
|
||||
let (program, args) = ("cmd", vec!["/c", "exit", "7"]);
|
||||
#[cfg(unix)]
|
||||
let (program, args) = ("sh", vec!["-c", "exit 7"]);
|
||||
|
||||
let status = fallback_command(
|
||||
Path::new(program),
|
||||
args.iter().map(OsString::from),
|
||||
Kind::TinySkia,
|
||||
)
|
||||
.status()
|
||||
.expect("假子进程必须能起来");
|
||||
assert_eq!(
|
||||
exit_code(status.code()),
|
||||
7,
|
||||
"子进程退出 7,父进程必须透传 7;{status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 子进程**被信号杀死**(`code()` 为 `None`):走兜底码,不 panic、不当成功。
|
||||
///
|
||||
/// 这是纯函数测试,所以在 Windows 上也能跑(真造一个信号死亡的 `ExitStatus`
|
||||
/// 需要 `ExitStatusExt`,只有 Unix 有;而这里测的正是「拿不到码」那一刻的行为)。
|
||||
#[test]
|
||||
fn signal_death_falls_back_to_a_nonzero_code() {
|
||||
assert_eq!(exit_code(Some(0)), 0, "成功必须透传 0");
|
||||
assert_eq!(exit_code(Some(7)), 7);
|
||||
let fallback = exit_code(None);
|
||||
assert_ne!(fallback, 0, "拿不到退出码绝不能当成功");
|
||||
assert_eq!(fallback, 2, "兜底码与 main 的「参数或运行错误」一致");
|
||||
}
|
||||
|
||||
/// 没有兜底可用的两行(强制 CPU / 未编 wgpu):失败就原样把错误交回去,不许 respawn。
|
||||
#[test]
|
||||
fn no_fallback_means_no_respawn() {
|
||||
let cpu = decision::decide(crate::Backend::Cpu, &probe("IntegratedGpu"), true);
|
||||
let evidence = std::path::PathBuf::from("out.png");
|
||||
assert_eq!(cpu.fallback, None);
|
||||
assert_eq!(on_failure(cpu, None), None);
|
||||
assert_eq!(
|
||||
on_failure(cpu, Some(&evidence)),
|
||||
None,
|
||||
"强制 CPU 也不该走到「拒绝降级」那条提示上(它压根没想上 GPU)"
|
||||
);
|
||||
|
||||
let not_compiled = decision::decide(crate::Backend::Auto, &probe("IntegratedGpu"), false);
|
||||
assert_eq!(on_failure(not_compiled, None), None);
|
||||
}
|
||||
|
||||
/// **窗口设置就是验收日志里那三个字段的来源**(M98 r1 p2-1 的守门)。
|
||||
///
|
||||
/// 抓得住:把 `decorations` 改回 `true`(自绘三键退回系统装饰)、把 `resizable` / `transparent`
|
||||
/// 打开 —— 这三种改动都会让 `[window]` 与 `[caps]` 两行与代码自相矛盾,而在此之前**371 个测试
|
||||
/// 全绿**(复审 M6 变异实证)。现在它们必须红在这里。
|
||||
/// 抓不住:winit 是否真按这些设置建窗(那要真机看窗口,见判据表「谁能验证」列)。
|
||||
#[test]
|
||||
fn window_settings_match_what_the_acceptance_logs_claim() {
|
||||
let settings = diagnostics::window_settings((1180.0, 760.0));
|
||||
|
||||
assert!(
|
||||
!settings.decorations,
|
||||
"M93 自绘三键:窗口不能带系统装饰(否则 macOS 上会出现两套标题栏)"
|
||||
);
|
||||
assert!(
|
||||
settings.resizable,
|
||||
"验收要验「能否缩放」,窗口必须声明可缩放"
|
||||
);
|
||||
assert!(
|
||||
!settings.transparent,
|
||||
"整窗透明只在 win32 拿到句柄后下发(D36),窗口创建侧不许打开它"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.min_size,
|
||||
Some(MIN_SIZE),
|
||||
"最小尺寸档(880×560)是矩阵的一格,不能被改动"
|
||||
);
|
||||
|
||||
// 日志确实从上面这个 settings 取值(而不是另抄一份字面量)
|
||||
let facts = WindowFacts::from_settings(&settings);
|
||||
let line = crate::renderer::acceptance::window_text(
|
||||
&facts,
|
||||
None,
|
||||
"default",
|
||||
Some(super::super::APP_TITLE),
|
||||
);
|
||||
assert!(line.contains("resizable=true"), "{line}");
|
||||
assert!(line.contains("decorations=false"), "{line}");
|
||||
assert!(line.contains("transparent=false"), "{line}");
|
||||
assert!(line.contains("size=1180x760"), "{line}");
|
||||
assert!(line.contains("min=880x560"), "{line}");
|
||||
|
||||
// 抽字段这一步必须**忠实**:拿一份处处相反的 settings 进来,facts 必须跟着反
|
||||
// (抓的是「from_settings 里又写死一份常量」这条回退路 —— M98 r1 M7 变异实证它当年漏抓)
|
||||
let odd = iced::window::Settings {
|
||||
size: iced::Size::new(640.0, 480.0),
|
||||
min_size: None,
|
||||
decorations: true,
|
||||
resizable: false,
|
||||
transparent: true,
|
||||
..iced::window::Settings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
WindowFacts::from_settings(&odd),
|
||||
WindowFacts {
|
||||
size: (640.0, 480.0),
|
||||
min: None,
|
||||
resizable: false,
|
||||
decorations: true,
|
||||
transparent: true,
|
||||
},
|
||||
"from_settings 必须逐字段搬运,不能在内部再写死一份常量"
|
||||
);
|
||||
|
||||
// 反向:把事实翻个身,日志必须跟着变(证明没有第二份真值)
|
||||
let flipped = WindowFacts {
|
||||
decorations: true,
|
||||
..facts
|
||||
};
|
||||
let line = crate::renderer::acceptance::window_text(
|
||||
&flipped,
|
||||
None,
|
||||
"default",
|
||||
Some(super::super::APP_TITLE),
|
||||
);
|
||||
assert!(
|
||||
line.contains("decorations=true"),
|
||||
"日志必须跟着 settings 走:{line}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
|
||||
//!
|
||||
//! ## 为什么要有这一层
|
||||
//!
|
||||
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
|
||||
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
|
||||
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
|
||||
//!
|
||||
//! 现在这些行同时进 `launch::launcher_log`:
|
||||
//!
|
||||
//! ```text
|
||||
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
|
||||
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
|
||||
//! ```
|
||||
//!
|
||||
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
|
||||
//!
|
||||
//! ## 哪些行进日志、哪些只进 stderr
|
||||
//!
|
||||
//! 判据(M188 复审后收紧成这一句):**逐帧度量不接;显式取证开关与一次性事故行可接。**
|
||||
//!
|
||||
//! | 进日志 | 只进 stderr |
|
||||
//! |---|---|
|
||||
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | **验收与出图矩阵的逐帧度量**:`[window]` / `[perf]` / `[caps]` / `[theme] source=`(`renderer::acceptance/**`,一次启动上百行) |
|
||||
//! | **一次性事故行**:`[gpu] <backend> 初始化失败(具名)` / `[gpu] 失败详情(具名)`(`renderer::report_failure`)、`[gpu] iced_wgpu: …Selected: AdapterInfo…`(`log_bridge`)、`[probe] request=… -> ICED_BACKEND=…`(`probe::apply_with_preflight`)、`[gpu-preflight]`(`ULL_PREFLIGHT=1`) | 组件几何探针的断言输出(`components/**` 的 `[feedback-probe]` / gallery 页面里的 `[gallery]`) |
|
||||
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
|
||||
//! | 显式开关下的一次性行:`[tick]`(`ULL_TICK_TRACE=1`,≈1 行/秒 ≈ 4.8 小时才滚一次)、`[shot]` 的通道提示 | — |
|
||||
//!
|
||||
//! 为什么逐帧行不许接:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
|
||||
//! 逐帧写会在几分钟内把「事故现场」冲掉。反过来,`report_failure` 那两行**就是**事故本体——
|
||||
//! 双击启动没有 stderr,把它们留在 stderr 等于在最该有日志的路径上什么都没有(M188 复审 P2-1)。
|
||||
|
||||
/// 行首没有 `[tag]` 时的默认 scope。
|
||||
pub const APP_SCOPE: &str = "app";
|
||||
|
||||
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
|
||||
pub(crate) fn emit(line: &str) {
|
||||
eprintln!("{line}");
|
||||
let (scope, message) = split_scope(line);
|
||||
unlucky_core::launch::launcher_log::record(scope, message);
|
||||
}
|
||||
|
||||
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
|
||||
///
|
||||
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
|
||||
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
|
||||
let Some(rest) = line.strip_prefix('[') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
let Some((scope, tail)) = rest.split_once(']') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
|
||||
return (APP_SCOPE, line);
|
||||
}
|
||||
(scope, tail.trim_start())
|
||||
}
|
||||
|
||||
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
|
||||
macro_rules! diag {
|
||||
($($arg:tt)*) => {{
|
||||
crate::app::diag::emit(&format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use diag;
|
||||
|
||||
/// 串行化「会改 `launcher_log` 全局目录」的用例。
|
||||
///
|
||||
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
|
||||
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
|
||||
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
|
||||
#[cfg(test)]
|
||||
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_scope;
|
||||
|
||||
#[test]
|
||||
fn the_leading_bracket_becomes_the_scope() {
|
||||
assert_eq!(
|
||||
split_scope("[font] 求值耗时 12 ms"),
|
||||
("font", "求值耗时 12 ms")
|
||||
);
|
||||
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
|
||||
// 正文里的方括号不是标签
|
||||
assert_eq!(
|
||||
split_scope("[page] 占位动作 [x] pressed"),
|
||||
("page", "占位动作 [x] pressed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_without_a_tag_fall_back_to_the_default_scope() {
|
||||
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
|
||||
assert_eq!(
|
||||
split_scope("[a b] 空白不是标签"),
|
||||
("app", "[a b] 空白不是标签")
|
||||
);
|
||||
}
|
||||
|
||||
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
|
||||
///
|
||||
/// ## 为什么要「重试」
|
||||
///
|
||||
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
|
||||
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
|
||||
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
|
||||
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
|
||||
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
|
||||
///
|
||||
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
|
||||
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
|
||||
#[test]
|
||||
fn the_launcher_log_rolls_over_and_stays_bounded() {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
let _guard = super::LOG_STATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建夹具目录");
|
||||
let path = dir.join(launcher_log::FILE_NAME);
|
||||
let rolled = dir.join("launcher.1.log");
|
||||
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
|
||||
|
||||
for _ in 0..50 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&rolled);
|
||||
launcher_log::set_dir(&dir);
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 这一轮被别人的 set_dir 抢先了
|
||||
}
|
||||
launcher_log::record("boot", "第一行");
|
||||
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 追加落到了别人的目录,这一轮不算
|
||||
}
|
||||
|
||||
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
|
||||
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
|
||||
let size = std::fs::metadata(&path).expect("新文件").len();
|
||||
assert!(
|
||||
size < launcher_log::MAX_BYTES,
|
||||
"滚完之后新文件必须远小于上限:{size}"
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).expect("读新文件");
|
||||
assert!(text.contains("滚过之后这一行"), "{text}");
|
||||
|
||||
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
|
||||
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
|
||||
launcher_log::record("boot", "第二次滚动");
|
||||
assert!(rolled.is_file());
|
||||
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
|
||||
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
return;
|
||||
}
|
||||
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! 模态对话框(M187 接通 `DialogQueue`)+ 启动失败卡片。
|
||||
//!
|
||||
//! ## 为什么先补这一层
|
||||
//!
|
||||
//! 接线审计(M177)的结论很硬:`DialogQueue` / `DialogRequest` 在 app-iced 里**零引用**,
|
||||
//! 所有弹窗都画不出来。于是「像 PCL2 / HMCL 那样,启动失败时弹一个带崩溃说明的框」这件事
|
||||
//! 在此之前根本无处落脚。本模块是最小可用的一层:
|
||||
//!
|
||||
//! ```text
|
||||
//! presenter AppState::dialogs(唯一队列)
|
||||
//! │ current()
|
||||
//! ▼
|
||||
//! dialog::current(lang, request, card) ← 纯视图模型(测试打这里)
|
||||
//! │
|
||||
//! ▼
|
||||
//! dialog::view::layer(ui, base, modal) ← 模态遮罩 + 卡片 + 主/次按钮
|
||||
//! ```
|
||||
//!
|
||||
//! 交互:主按钮 / 次按钮 / 点遮罩 / `Esc` 都关当前条(`DialogQueue::close_current`)。
|
||||
//!
|
||||
//! ## 崩溃卡片额外带什么
|
||||
//!
|
||||
//! `DialogRequest`(presenter 的 DTO)只有标题/正文/按钮,而这次用户要的是**证据**:
|
||||
//! 退出码、一句人话原因、**文件路径**、可展开的崩溃说明、以及三个按钮(导出 / 打开文件夹 /
|
||||
//! 复制路径)。这些东西不进 `DialogRequest`——它是 presenter 的公共 DTO,塞进 UI 专用字段
|
||||
//! 会把「谁拥有证据」搅浑。做法是:队列管**弹窗**,[`CrashCard`] 管**这次失败的事实**,
|
||||
//! 两者按 `DialogId` 绑定,见 [`App::open_failure_dialog`]。
|
||||
//!
|
||||
//! 事实来源是 `unlucky_core::launch::crash_report`(启动线程收尾时登记的现场)。这里只读它,
|
||||
//! 不重算:**界面不读盘、不猜原因**。
|
||||
|
||||
use unlucky_core::launch::crash_report::{CrashReport, Evidence};
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dialogs::{DialogId, DialogKind, DialogSource};
|
||||
|
||||
use crate::app::state::App;
|
||||
|
||||
pub(super) mod export;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(super) mod view;
|
||||
|
||||
/// 崩溃卡片:一次失败的**事实**(不含任何结论)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrashCard {
|
||||
/// 现场。
|
||||
pub report: CrashReport,
|
||||
/// 「详细崩溃说明」是否展开。
|
||||
pub expanded: bool,
|
||||
/// 与哪一条队列请求绑定(关掉它时把卡片一起清掉)。
|
||||
pub dialog: DialogId,
|
||||
/// 上一次动作的结论(导出到哪 / 打不开文件夹的原因)。
|
||||
///
|
||||
/// 为什么写在卡片里而不是只推 Toast:app-iced 的 Toast 通道**至今没有任何渲染点**
|
||||
/// (`state.toasts` 只被推入与过期,`view` 里一次都没读)。导出这种「必须看见落到哪」的
|
||||
/// 结果不能挂在一条不显示的通道上。
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
/// 弹窗消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
/// 主按钮。
|
||||
Primary,
|
||||
/// 次按钮。
|
||||
Secondary,
|
||||
/// 点遮罩 / Esc。
|
||||
Dismiss,
|
||||
/// 展开 / 收起详细说明。
|
||||
Toggle,
|
||||
/// 打开日志文件夹。
|
||||
OpenFolder,
|
||||
/// 复制全部路径。
|
||||
CopyPaths,
|
||||
/// 复制第 `index` 条路径(`Modal::paths` 的下标)。
|
||||
CopyPath(usize),
|
||||
}
|
||||
|
||||
/// 视图模型:**测试与渲染共用**的一份「这一屏该出现什么」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(super) struct Modal {
|
||||
/// 标题。
|
||||
pub title: String,
|
||||
/// 正文(每行一段;都是运行时拼出来的 owned 字符串)。
|
||||
pub body: Vec<String>,
|
||||
/// 主按钮文字。
|
||||
pub primary: String,
|
||||
/// 次按钮文字(`None` = 不画)。
|
||||
pub secondary: Option<String>,
|
||||
/// 是否有次按钮(`Progress` 没有)。
|
||||
pub blocking_progress: bool,
|
||||
/// 崩溃卡片(普通弹窗为 `None`)。
|
||||
pub card: Option<CardView>,
|
||||
/// 动作结论(导出路径 / 失败原因);`None` = 还没有动作。
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// 崩溃卡片的展示内容。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(super) struct CardView {
|
||||
/// 展开 / 收起按钮的文字。
|
||||
pub toggle: String,
|
||||
/// 当前的展开态。
|
||||
pub expanded: bool,
|
||||
/// (标签, 路径)列表——**逐条可点**(点了复制该条)。
|
||||
pub paths: Vec<(String, String)>,
|
||||
/// 详细说明的小标题(`None` = 没有可展开的内容)。
|
||||
pub detail_title: Option<String>,
|
||||
/// 详细说明的正文行。
|
||||
pub detail_lines: Vec<String>,
|
||||
/// 三个按钮的文字。
|
||||
pub actions: [String; 3],
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
/// 正文全文(测试与「复制摘要」共用;换行拼起来)。
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn body_text(&self) -> String {
|
||||
self.body.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从队列当前条与卡片造视图模型(**纯函数**:不读盘、不看 App)。
|
||||
#[must_use]
|
||||
pub(super) fn current(
|
||||
lang: Lang,
|
||||
request: &DialogRequestView<'_>,
|
||||
card: Option<&CrashCard>,
|
||||
) -> Modal {
|
||||
let catalog = Catalog::of(lang);
|
||||
let mut modal = Modal {
|
||||
title: request.title.to_string(),
|
||||
body: vec![request.body.to_string()],
|
||||
primary: request.primary.to_string(),
|
||||
secondary: request.secondary.map(str::to_string),
|
||||
blocking_progress: request.blocking_progress,
|
||||
card: None,
|
||||
notice: None,
|
||||
};
|
||||
if let Some(card) = card {
|
||||
let detail = detail_of(&catalog, card);
|
||||
modal.body = failure_body(&catalog, &card.report);
|
||||
modal.primary = catalog.text(Key::CrashDialogExport).to_string();
|
||||
modal.secondary = Some(catalog.text(Key::CrashDialogClose).to_string());
|
||||
modal.card = Some(CardView {
|
||||
toggle: catalog
|
||||
.text(if card.expanded {
|
||||
Key::CrashDialogHideDetail
|
||||
} else {
|
||||
Key::CrashDialogShowDetail
|
||||
})
|
||||
.to_string(),
|
||||
expanded: card.expanded,
|
||||
paths: path_rows(&catalog, &card.report),
|
||||
detail_title: detail.0,
|
||||
detail_lines: detail.1,
|
||||
actions: [
|
||||
catalog.text(Key::CrashDialogExport).to_string(),
|
||||
catalog.text(Key::CrashDialogOpenFolder).to_string(),
|
||||
catalog.text(Key::CrashDialogCopyPaths).to_string(),
|
||||
],
|
||||
});
|
||||
modal.notice = card.outcome.clone();
|
||||
}
|
||||
modal
|
||||
}
|
||||
|
||||
/// 队列当前条的最小视图(避免把 `DialogRequest` 的细节漏进页面层)。
|
||||
pub(super) struct DialogRequestView<'a> {
|
||||
/// 标题。
|
||||
pub title: &'a str,
|
||||
/// 正文。
|
||||
pub body: &'a str,
|
||||
/// 主按钮。
|
||||
pub primary: &'a str,
|
||||
/// 次按钮。
|
||||
pub secondary: Option<&'a str>,
|
||||
/// 进度框不可点主按钮。
|
||||
pub blocking_progress: bool,
|
||||
}
|
||||
|
||||
impl<'a> DialogRequestView<'a> {
|
||||
/// 从 presenter 的请求取视图。
|
||||
pub(super) fn of(request: &'a unlucky_presenter::dialogs::DialogRequest) -> Self {
|
||||
Self {
|
||||
title: &request.title,
|
||||
body: &request.body,
|
||||
primary: request.kind.primary_label(),
|
||||
secondary: request.kind.secondary_label(),
|
||||
blocking_progress: request.kind.is_blocking_progress(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败正文:退出码 + **一句人话原因** + 证据形态。
|
||||
///
|
||||
/// 三件事都必须有——用户投诉的原话是「找半天没找到崩溃日志」,所以第三句不是锦上添花:
|
||||
/// 它要么指向游戏写的那份,要么明确说「这次没有,下面是捕获到的输出」。
|
||||
fn failure_body(catalog: &Catalog, report: &CrashReport) -> Vec<String> {
|
||||
let mut body = Vec::new();
|
||||
if let Some(code) = report.exit_code {
|
||||
body.push(catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]));
|
||||
}
|
||||
if let Some(stage) = &report.stage {
|
||||
let label = stage_label(catalog, stage);
|
||||
body.push(catalog.render(Key::CrashDialogStageReason, &[("stage", label)]));
|
||||
} else if report.exit_code == Some(0) {
|
||||
body.push(catalog.text(Key::CrashDialogSilentFailure).to_string());
|
||||
} else {
|
||||
body.push(catalog.text(Key::CrashDialogStartedThenDied).to_string());
|
||||
}
|
||||
body.push(
|
||||
match report.evidence() {
|
||||
Evidence::GameCrashReport => catalog.text(Key::CrashDialogCrashReportFound),
|
||||
Evidence::CapturedOutput => catalog.text(Key::CrashDialogNoGameLog),
|
||||
Evidence::Nothing => catalog.text(Key::CrashDialogNoLogAtAll),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
/// 装配阶段 slug → 文案(**不**复用 core 的中文诊断串)。
|
||||
fn stage_label(catalog: &Catalog, slug: &str) -> &'static str {
|
||||
let key = match slug {
|
||||
"version_manifest" => Key::LaunchStageVersionManifest,
|
||||
"inheritance" => Key::LaunchStageInheritance,
|
||||
"instance_config" => Key::LaunchStageInstanceConfig,
|
||||
"isolation" => Key::LaunchStageIsolation,
|
||||
"identity" => Key::LaunchStageIdentity,
|
||||
_ => Key::LaunchStageSpawn,
|
||||
};
|
||||
catalog.text(key)
|
||||
}
|
||||
|
||||
/// 路径行(**启动器日志目录 + 该实例 latest.log + crash-reports 目录 + 会话 JSONL**)。
|
||||
fn path_rows(catalog: &Catalog, report: &CrashReport) -> Vec<(String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(log) = &report.session_log {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogSessionLog).to_string(),
|
||||
log.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = &report.launcher_logs {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogLauncherLogs).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(path) = report.latest_log_path() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogGameLog).to_string(),
|
||||
path.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(dir) = report.crash_reports_dir() {
|
||||
rows.push((
|
||||
catalog.text(Key::CrashDialogCrashReports).to_string(),
|
||||
dir.display().to_string(),
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// 详细说明:有崩溃报告贴 Description/栈片段,否则贴启动器捕获的尾巴。
|
||||
fn detail_of(catalog: &Catalog, card: &CrashCard) -> (Option<String>, Vec<String>) {
|
||||
let report = &card.report;
|
||||
if let Some(artifact) = report
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.source == "crash_report")
|
||||
{
|
||||
let mut lines = Vec::new();
|
||||
if let Some(excerpt) = &artifact.excerpt {
|
||||
lines.extend(excerpt.lines().map(str::to_string));
|
||||
} else {
|
||||
lines.push(artifact.path.display().to_string());
|
||||
}
|
||||
let title = format!(
|
||||
"{} {}",
|
||||
catalog.text(Key::LogsDiagnosisTitle),
|
||||
artifact.path.display()
|
||||
);
|
||||
return (Some(title), lines);
|
||||
}
|
||||
if report.stderr_tail.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
let title = catalog.render(
|
||||
Key::CrashDialogCapturedTail,
|
||||
&[("count", &report.stderr_tail.len().to_string())],
|
||||
);
|
||||
(Some(title), report.stderr_tail.clone())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 与 App 的接线
|
||||
|
||||
impl App {
|
||||
/// 队列当前条 → 视图模型(`view()` 与测试共用同一个入口)。
|
||||
pub(super) fn dialog_modal(&self) -> Option<Modal> {
|
||||
let request = self.state.dialogs.current()?;
|
||||
let view = DialogRequestView::of(request);
|
||||
let card = self
|
||||
.dialog_card
|
||||
.as_ref()
|
||||
.filter(|card| card.dialog == request.id);
|
||||
Some(current(self.lang(), &view, card))
|
||||
}
|
||||
|
||||
/// 收到一次启动收尾:失败就弹框(成功不打扰)。
|
||||
///
|
||||
/// 只在**真的失败**时弹:`LogOutcome::Ok` 且没有阶段失败 ⇒ 什么都不做,与既有 Toast 口径一致。
|
||||
pub(super) fn open_failure_dialog(
|
||||
&mut self,
|
||||
finished: &unlucky_presenter::dto::LaunchFinished,
|
||||
) {
|
||||
let failed = finished.outcome != unlucky_presenter::dto::LogOutcome::Ok
|
||||
|| finished.failure.is_some();
|
||||
if !failed {
|
||||
return;
|
||||
}
|
||||
let Some(report) = unlucky_core::launch::crash_report::failing_launch() else {
|
||||
return;
|
||||
};
|
||||
if !finished.session.is_empty() && report.session != finished.session {
|
||||
// 登记的是另一场(并发启动):不拿它去解释这一场。
|
||||
return;
|
||||
}
|
||||
let catalog = self.lang().catalog();
|
||||
let crashed = report.started();
|
||||
let title = catalog.text(if crashed {
|
||||
Key::CrashDialogTitleCrashed
|
||||
} else {
|
||||
Key::CrashDialogTitleFailed
|
||||
});
|
||||
let summary = self.dialog_summary(&report);
|
||||
let at = self.lang();
|
||||
let id = self.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
summary,
|
||||
DialogKind::Confirm {
|
||||
primary: Catalog::of(at).text(Key::CrashDialogExport).to_string(),
|
||||
secondary: Catalog::of(at).text(Key::CrashDialogClose).to_string(),
|
||||
},
|
||||
);
|
||||
self.dialog_card = Some(CrashCard {
|
||||
report,
|
||||
// 默认展开:正文已经说「下面是捕获的最后若干行」,收着就成了空口承诺。
|
||||
expanded: true,
|
||||
dialog: id,
|
||||
outcome: None,
|
||||
});
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 队列标题下的那一行摘要(弹窗正文由 [`current`] 按卡片重算,这里给它一份独立可读的短句)。
|
||||
fn dialog_summary(&self, report: &CrashReport) -> String {
|
||||
let catalog = self.lang().catalog();
|
||||
match report.exit_code {
|
||||
Some(code) => catalog.render(Key::CrashDialogExitCode, &[("code", &code.to_string())]),
|
||||
None => catalog.text(Key::CrashDialogTitleFailed).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 关掉当前弹窗(主/次按钮、遮罩、`Esc` 共用)。
|
||||
pub(super) fn close_dialog(&mut self) {
|
||||
let closed = self.state.dialogs.current().map(|request| request.id);
|
||||
self.state.dialogs.close_current();
|
||||
if self
|
||||
.dialog_card
|
||||
.as_ref()
|
||||
.is_some_and(|card| closed == Some(card.dialog))
|
||||
{
|
||||
self.dialog_card = None;
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 展开 / 收起详细说明。
|
||||
pub(super) fn toggle_dialog_detail(&mut self) {
|
||||
if let Some(card) = &mut self.dialog_card {
|
||||
card.expanded = !card.expanded;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 失败卡片的三个动作:导出诊断包 / 打开文件夹 / 复制路径。
|
||||
//!
|
||||
//! 与 [`super`] 分开是因为职责不同:那边管**弹窗与事实**,这边管**把事实带出界面**。
|
||||
//! 打包本体在 `unlucky_core::launch::crash_bundle`(脱敏、上限、清单都在那一处),
|
||||
//! 本文件只负责「放哪、叫什么名字、怎么打开」。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::crash_bundle::{self, BundleEntry, BundleSpec};
|
||||
use unlucky_core::launch::crash_report::CrashReport;
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 一次导出的结论(**成功也带事实**:路径、字节数、有没有文件被截断)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Exported {
|
||||
/// 给用户看的那一句(已本地化)。
|
||||
pub text: String,
|
||||
/// 是否成功。
|
||||
pub ok: bool,
|
||||
/// 有几个文件被截断(`>0` 时界面要明说「证据是残的」)。
|
||||
pub truncated: usize,
|
||||
}
|
||||
|
||||
/// 导出诊断包:打到「启动器日志目录 / exports / 一场一个文件名」。
|
||||
///
|
||||
/// 返回的那句话说清**落到哪、多少字节**(用户要的第一件事就是「文件在哪」);
|
||||
/// 有文件被截断时再补一句——别让用户以为包里的证据是完整的。
|
||||
pub(crate) fn export(report: &CrashReport, lang: Lang) -> Exported {
|
||||
let catalog = Catalog::of(lang);
|
||||
let Some(logs) = &report.launcher_logs else {
|
||||
return Exported {
|
||||
text: catalog.render(
|
||||
Key::CrashDialogExportFailed,
|
||||
&[("error", "no launcher log dir")],
|
||||
),
|
||||
ok: false,
|
||||
truncated: 0,
|
||||
};
|
||||
};
|
||||
let stamp =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now())
|
||||
.replace([':', '.', 'T'], "-")
|
||||
.trim_end_matches('Z')
|
||||
.to_string();
|
||||
let name = if report.session.is_empty() {
|
||||
&report.instance
|
||||
} else {
|
||||
&report.session
|
||||
};
|
||||
let target = crash_bundle::default_target_dir(logs).join(format!("crash-{name}-{stamp}.zip"));
|
||||
match crash_bundle::write_bundle(&spec_of(report), &target) {
|
||||
Ok(bundle) => {
|
||||
let mut text = catalog.render(
|
||||
Key::CrashDialogExportDone,
|
||||
&[
|
||||
("bytes", &bundle.bytes.to_string()),
|
||||
("path", &bundle.path.display().to_string()),
|
||||
],
|
||||
);
|
||||
if bundle.truncated > 0 {
|
||||
text.push('\n');
|
||||
text.push_str(&catalog.render(
|
||||
Key::CrashDialogExportTruncated,
|
||||
&[("count", &bundle.truncated.to_string())],
|
||||
));
|
||||
}
|
||||
Exported {
|
||||
text,
|
||||
ok: true,
|
||||
truncated: bundle.truncated,
|
||||
}
|
||||
}
|
||||
Err(error) => Exported {
|
||||
text: catalog.render(Key::CrashDialogExportFailed, &[("error", &error)]),
|
||||
ok: false,
|
||||
truncated: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 现场 → 诊断包清单(**进包的东西必须与弹窗上列出的路径一致**)。
|
||||
fn spec_of(report: &CrashReport) -> BundleSpec {
|
||||
let mut entries: Vec<BundleEntry> = Vec::new();
|
||||
let push = |entries: &mut Vec<BundleEntry>, group: &'static str, path: &std::path::Path| {
|
||||
if path.exists() {
|
||||
entries.push(BundleEntry::new(group, path));
|
||||
}
|
||||
};
|
||||
if let Some(logs) = &report.launcher_logs {
|
||||
push(&mut entries, "launcher", &logs.join("launcher.log"));
|
||||
push(&mut entries, "launcher", &logs.join("launcher.1.log"));
|
||||
push(&mut entries, "launcher", &logs.join("install.log"));
|
||||
}
|
||||
if let Some(session) = &report.session_log {
|
||||
// 源文件名就是会话 id:钉成 `session.jsonl`,包内名与清单名同时不再泄漏 id,
|
||||
// 也不会出现「清单写 <redacted:token> 而包里是原始 id」(r1 复审 P2-2)。
|
||||
if session.exists() {
|
||||
entries.push(BundleEntry::named("session", session, "session.jsonl"));
|
||||
}
|
||||
}
|
||||
for artifact in &report.artifacts {
|
||||
// `latest.log` 在下面单独收一次(它同时是扫描结果与固定项,重复收会变成两份)。
|
||||
if artifact.source != "latest_log" {
|
||||
push(&mut entries, "game", &artifact.path);
|
||||
}
|
||||
}
|
||||
if let Some(latest) = report.latest_log_path() {
|
||||
push(&mut entries, "game", &latest);
|
||||
}
|
||||
if let Some(json) = &report.version_json {
|
||||
push(&mut entries, "instance", json);
|
||||
}
|
||||
let mut secrets = vec![report.session.clone()];
|
||||
secrets.retain(|secret| !secret.is_empty());
|
||||
BundleSpec {
|
||||
instance: report.name.clone(),
|
||||
version: report.version.clone(),
|
||||
session: report.session.clone(),
|
||||
exit_code: report.exit_code,
|
||||
exit_type: report.exit_type.clone(),
|
||||
uptime_ms: report.uptime_ms,
|
||||
command: report.command.clone(),
|
||||
java: report.java.clone(),
|
||||
system: report.system.clone(),
|
||||
notes: report.note_lines(),
|
||||
missing: report.missing_lines(),
|
||||
entries,
|
||||
secrets,
|
||||
home: std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开「日志文件夹」:有现场就开实例运行目录,否则开启动器日志目录。
|
||||
///
|
||||
/// 平台差异只在这里一处(`explorer` / `open` / `xdg-open`),不往业务代码里散写 `cfg`。
|
||||
pub(crate) fn open_folder(report: &CrashReport, lang: Lang) -> String {
|
||||
let catalog = Catalog::of(lang);
|
||||
let target = report
|
||||
.run_dir
|
||||
.clone()
|
||||
.filter(|dir| dir.is_dir())
|
||||
.or_else(|| report.launcher_logs.clone().filter(|dir| dir.is_dir()));
|
||||
let Some(dir) = target else {
|
||||
return catalog.render(
|
||||
Key::CrashDialogOpenFailed,
|
||||
&[("error", "no folder on disk")],
|
||||
);
|
||||
};
|
||||
match open_dir(&dir) {
|
||||
Ok(()) => String::new(),
|
||||
Err(error) => catalog.render(Key::CrashDialogOpenFailed, &[("error", &error)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开一个目录(M188:设置页/日志页的「打开日志文件夹」也走这里,**不再另写一套 `cfg`**)。
|
||||
///
|
||||
/// 失败文本带完整路径:调用方把它显示/记日志出来,用户至少能照着路径去找。
|
||||
pub(crate) fn open_dir(dir: &std::path::Path) -> Result<(), String> {
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{}(目录不存在)", dir.display()));
|
||||
}
|
||||
spawn_opener(dir).map_err(|error| format!("{}(打开失败:{error})", dir.display()))
|
||||
}
|
||||
|
||||
/// 平台各自的「打开这个目录」实现。
|
||||
///
|
||||
/// ## 为什么这三处**故意**不走 `unlucky_core::platform::child::quiet`(M217)
|
||||
///
|
||||
/// 这一处 spawn 的**目的就是让桌面上出现一个可见的东西**(文件管理器窗口 / Finder 窗口 /
|
||||
/// 桌面环境的文件管理器)。`quiet` 的语义是「把子进程的窗口抑制掉」,用在这里是把目的本身
|
||||
/// 抑制掉——改错了用户点「打开文件夹」就只看到「打不开」的报错。
|
||||
///
|
||||
/// 而且从窗口角度也不需要它:`explorer.exe` / `open` / `xdg-open` 自己都是 GUI 侧程序,由
|
||||
/// 无控制台的 GUI 父进程 spawn 时 Windows 不会给它们新建控制台。所以这三处在
|
||||
/// `crates/core/tests/child_quiet_guard.rs` 的 `ALLOWED_ROLES` 里登记成「目录打开器」角色,
|
||||
/// **不是**漏网。别把它们改成 `quiet`。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
// `explorer` 不用 shell:路径里的空格与 `&` 不会被当成命令分隔符。
|
||||
std::process::Command::new("explorer")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("open")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn spawn_opener(dir: &std::path::Path) -> Result<(), String> {
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 「复制路径」的一段文本(每条一行,标签 + 路径)。
|
||||
pub(crate) fn paths_text(report: &CrashReport) -> String {
|
||||
report
|
||||
.path_lines()
|
||||
.into_iter()
|
||||
.map(|(label, path)| format!("{label}: {}", path.display()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 弹窗的三条守卫:**队列真的被消费**、**运行时中文真的进了正文**、**两种失败说两句话**。
|
||||
//!
|
||||
//! ## 这些用例抓得住什么 / 抓不住什么(说在明处)
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`queue_drives_the_modal_through_refresh_view`] | 队列里有请求 ⇒ `refresh_view` 之后 `view()` 拿到的模型里就有它(把消费点删掉即红) | 像素:`view()` 是否真的把控件塞进 iced 树(由 `DevDocs/artifacts/m187/` 的截图守) |
|
||||
//! | [`runtime_chinese_reaches_the_body`] | 运行时拼出来的中文(`render` / `format!` 的 owned 串)确实在正文里 | 字形是否栅格化出来(同上,wgpu / tiny-skia 并排图) |
|
||||
//! | [`two_failure_shapes_say_two_different_things`] | 没有 crash-report 时**不会**说成「有崩溃报告」,而且必须贴出捕获行 | 那句话读起来顺不顺(review) |
|
||||
//!
|
||||
//! 「owned 文字在 wgpu 下丢字」这条 M190 的怀疑在本 branch 上**没有复现**(A/B 图见
|
||||
//! `DevDocs/artifacts/m187/ab-wgpu.png` 与 `ab-tinyskia.png`):同一串中文走 `text_role` 与
|
||||
//! `text_role_owned` 在两种后端下逐字相同。所以这里不编一条「为了变绿而改渲染路径」的断言。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogQueue, DialogSource};
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
|
||||
use super::{current, path_rows, CrashCard, DialogRequestView};
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn headless() -> App {
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
unlucky_design::color::SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn report(with_crash_report: bool, tail: Vec<String>) -> CrashReport {
|
||||
let run_dir = PathBuf::from("/instances/survival");
|
||||
let artifacts = if with_crash_report {
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".to_string(),
|
||||
path: run_dir.join("crash-reports/crash-2026-11-08.txt"),
|
||||
excerpt: Some("Description: Initializing game".to_string()),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
CrashReport {
|
||||
instance: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
version: "1.20.4-fabric".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/9f2c.jsonl")),
|
||||
version_json: Some(PathBuf::from("/game/versions/1.20.4/1.20.4.json")),
|
||||
launcher_logs: Some(PathBuf::from("/logs")),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".to_string(),
|
||||
uptime_ms: 1_000,
|
||||
command: "javaw -Xmx4096m".to_string(),
|
||||
java: "javaw.exe (21)".to_string(),
|
||||
system: "os=windows".to_string(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 守卫①:队列消费。请求进队列 ⇒ `refresh_view` 之后模型里有它;空队列 ⇒ 没有。
|
||||
///
|
||||
/// **变异证明**:把 `refresh_view` 末尾那行 `self.dialog_model = self.dialog_modal();` 删掉,
|
||||
/// 本用例第二段立刻红(`dialog_model` 恒 `None`)。
|
||||
#[test]
|
||||
fn queue_drives_the_modal_through_refresh_view() {
|
||||
let mut app = headless();
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
app.dialog_model.is_none(),
|
||||
"空队列不该画任何弹窗(现状:队列零引用,弹窗从不渲染)"
|
||||
);
|
||||
|
||||
let title = app.lang().catalog().text(Key::CrashDialogTitleFailed);
|
||||
app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
title,
|
||||
"正文一行",
|
||||
DialogKind::Confirm {
|
||||
primary: "主要".to_string(),
|
||||
secondary: "次要".to_string(),
|
||||
},
|
||||
);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
|
||||
let modal = app.dialog_model.as_ref().expect("队列里有请求就必须有模型");
|
||||
assert_eq!(modal.title, title);
|
||||
assert_eq!(modal.primary, "主要");
|
||||
assert_eq!(modal.secondary.as_deref(), Some("次要"));
|
||||
}
|
||||
|
||||
/// 守卫②:**运行时拼出来的中文**必须真的进正文(owned 串走 `Catalog::render` / `format!`)。
|
||||
///
|
||||
/// **变异证明**:把 `failure_body` 里的 `catalog.render(Key::CrashDialogExitCode, …)` 换成固定
|
||||
/// 英文,本用例第一处断言立刻红。
|
||||
#[test]
|
||||
fn runtime_chinese_reaches_the_body() {
|
||||
let lang = Lang::ZhCn;
|
||||
let card = CrashCard {
|
||||
report: report(false, vec!["java.lang.RuntimeException: boom".to_string()]),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"游戏崩溃",
|
||||
"",
|
||||
DialogKind::Confirm {
|
||||
primary: String::new(),
|
||||
secondary: String::new(),
|
||||
},
|
||||
);
|
||||
let request = queue.current().expect("当前条");
|
||||
let view = DialogRequestView::of(request);
|
||||
let modal = current(lang, &view, Some(&card));
|
||||
let body = modal.body_text();
|
||||
|
||||
// 退出码那句是 `render`(带参数)拼出来的:必须带中文与数字。
|
||||
assert!(body.contains("退出码 1"), "{body}");
|
||||
assert!(body.contains("游戏进程起来了"), "{body}");
|
||||
// 没有 crash-report 时必须说「没生成」+「下面是捕获的行」。
|
||||
assert!(body.contains("没有生成崩溃报告"), "{body}");
|
||||
assert!(body.contains("捕获"), "{body}");
|
||||
// 英文界面里同样的三句不许出现中文(反向守卫:i18n 不是只往一个方向写)。
|
||||
let en = current(Lang::En, &view, Some(&card));
|
||||
assert!(!en.body_text().contains('崩'), "{}", en.body_text());
|
||||
}
|
||||
|
||||
/// 守卫③:两种失败**说两句不同的话**,而且都可以导出(事实齐全)。
|
||||
#[test]
|
||||
fn two_failure_shapes_say_two_different_things() {
|
||||
let lang = Lang::ZhCn;
|
||||
let with = report(true, vec!["stack line".to_string()]);
|
||||
let without = report(false, vec!["Error: could not find java.dll".to_string()]);
|
||||
|
||||
assert_ne!(with.evidence(), without.evidence());
|
||||
let with_card = CrashCard {
|
||||
report: with.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let without_card = CrashCard {
|
||||
report: without.clone(),
|
||||
expanded: true,
|
||||
dialog: 1,
|
||||
outcome: None,
|
||||
};
|
||||
let mut queue = DialogQueue::new();
|
||||
queue.push(
|
||||
DialogSource::UserAction,
|
||||
"t",
|
||||
"",
|
||||
DialogKind::Error {
|
||||
primary: "p".to_string(),
|
||||
hint: None,
|
||||
},
|
||||
);
|
||||
let request = queue.current().unwrap();
|
||||
let view = DialogRequestView::of(request);
|
||||
|
||||
let crash = current(lang, &view, Some(&with_card));
|
||||
let no_log = current(lang, &view, Some(&without_card));
|
||||
assert_ne!(crash.body_text(), no_log.body_text());
|
||||
assert!(crash.body_text().contains("写出了崩溃报告"));
|
||||
assert!(!no_log.body_text().contains("写出了崩溃报告"));
|
||||
// 没有 crash-report 的那一态仍然有可展开的细节(贴的是捕获行)与四条路径。
|
||||
assert!(no_log.card.as_ref().expect("卡片").detail_title.is_some());
|
||||
assert_eq!(path_rows(&lang.catalog(), &without).len(), 4);
|
||||
assert!(path_rows(&lang.catalog(), &without)
|
||||
.iter()
|
||||
.any(|(_, path)| path.ends_with("latest.log")));
|
||||
}
|
||||
|
||||
/// 弹窗开/关与卡片的绑定:关掉之后两样都要空(否则下一次失败会读到上一次的现场)。
|
||||
#[test]
|
||||
fn closing_the_dialog_clears_the_card() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, vec!["line".to_string()]));
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 1_000,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
assert!(app.state.dialogs.current().is_some(), "失败必须弹框");
|
||||
assert!(app.dialog_card.is_some());
|
||||
|
||||
app.close_dialog();
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
assert!(app.dialog_card.is_none());
|
||||
}
|
||||
|
||||
/// 成功收尾不打扰用户。
|
||||
#[test]
|
||||
fn a_successful_launch_does_not_pop_anything() {
|
||||
let mut app = headless();
|
||||
crash_report::publish(report(false, Vec::new()));
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "9f2c".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
exit_code: Some(0),
|
||||
uptime_ms: 90_000,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
}
|
||||
|
||||
/// P2-1:**卡片那一层只能是卡片大**。
|
||||
///
|
||||
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
|
||||
/// 卡片外任何一次点击都先被它捕获,遮罩永远收不到(「点遮罩关闭」形同虚设)。
|
||||
/// `Widget::size_hint()` 不需要渲染器就能问,所以这条能在单测里跑:卡片层若声明
|
||||
/// `Fill`/`Fill`,就等于「命中区 = 整窗」。
|
||||
///
|
||||
/// **抓不住什么**:端到端指针派发(本仓没有事件 harness,`Element` 的 `layout`/`update`
|
||||
/// 需要真渲染器)。所以这条守的是**结构属性**,不是「点一下真的关了」。
|
||||
#[test]
|
||||
fn the_card_layer_is_card_sized_so_the_backdrop_can_be_clicked() {
|
||||
use iced::Length;
|
||||
|
||||
let mut app = headless();
|
||||
app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
"t",
|
||||
"b",
|
||||
DialogKind::Confirm {
|
||||
primary: "p".to_string(),
|
||||
secondary: "s".to_string(),
|
||||
},
|
||||
);
|
||||
let modal = {
|
||||
let request = app.state.dialogs.current().expect("当前条");
|
||||
let view = DialogRequestView::of(request);
|
||||
super::current(app.lang(), &view, None)
|
||||
};
|
||||
let plane = super::view::card_plane(app.ui(), &modal);
|
||||
let hint = iced::advanced::Widget::size_hint(plane.as_widget());
|
||||
// 任一维是 `Fill` 就意味着「跟随父级 = 整窗」(外层是 Fill 的居中容器),
|
||||
// 命中区于是变成整窗 —— 那正是 r1 抓到的原实现。
|
||||
assert_ne!(
|
||||
hint.width,
|
||||
Length::Fill,
|
||||
"卡片层宽度不能 Fill(会撑成整窗)"
|
||||
);
|
||||
assert_ne!(
|
||||
hint.height,
|
||||
Length::Fill,
|
||||
"卡片层高度不能 Fill(会撑成整窗)"
|
||||
);
|
||||
}
|
||||
|
||||
/// P1-1:遮罩必须是**不透明预合成**,而且两档都**压暗**(深色档尤其)。
|
||||
///
|
||||
/// 抓得住 r1 实测的那个 p1:`Role::OnSurface @ 32%` 在深色档是「近白 @32%」⇒ 遮罩把整窗
|
||||
/// 提亮(实测 (9,21,28) → (129,138,145))。这里用亮度直接判方向:遮罩必须比画布暗。
|
||||
///
|
||||
/// 抓不住:具体色值对不对(那是令牌层的事)。
|
||||
#[test]
|
||||
fn the_backdrop_is_an_opaque_precomposite_and_darkens_both_flavors() {
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
let luma = |color: iced::Color| 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
|
||||
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
|
||||
// 断言**视图真正用的那一份**(不是组件层的默认值):改回「OnSurface @32%」这里就红。
|
||||
let backdrop = super::view::backdrop_color(&ui);
|
||||
assert_eq!(
|
||||
backdrop.a, 1.0,
|
||||
"{flavor:?}:遮罩必须是不透明预合成(D27:CPU 档零半透明)"
|
||||
);
|
||||
|
||||
let canvas = crate::theme::to_iced(
|
||||
unlucky_design::color::Scheme::baseline(flavor)
|
||||
.get(MaterialLayer::Canvas.surface_role()),
|
||||
);
|
||||
assert!(
|
||||
luma(backdrop) < luma(canvas),
|
||||
"{flavor:?}:遮罩必须比画布暗(当前 lane {:.3} vs 画布 {:.3})—— 用 Role::OnSurface 当遮罩会在深色档把整窗提亮",
|
||||
luma(backdrop),
|
||||
luma(canvas)
|
||||
);
|
||||
assert_eq!(Role::Surface, MaterialLayer::Canvas.surface_role());
|
||||
}
|
||||
}
|
||||
|
||||
/// P2-4:启动那一拍的**事实**确实进了启动器日志(登记目录后一次刷出)。
|
||||
///
|
||||
/// 抓得住:记录函数被拆掉/改空(缓冲里就什么都没有)。
|
||||
/// 抓不住:`on_boot` 有没有调它 —— 那靠这条用例旁边的调用点与 `host/launch.rs` 的 `set_dir`,
|
||||
/// 真机第一次崩溃才是最终验收闸门(复审与我的一致口径)。
|
||||
#[test]
|
||||
fn boot_facts_are_recorded_into_the_launcher_log() {
|
||||
// 与 `app/diag.rs` 的滚动闸串行:两条用例都动同一个进程级单例目录。
|
||||
let _guard = crate::app::diag::LOG_STATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!("ull-m187-bootlog-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
unlucky_core::launch::launcher_log::set_dir(&dir);
|
||||
|
||||
let app = headless();
|
||||
crate::app::update::boot_env::record_boot_log(&app);
|
||||
|
||||
let path = dir.join(unlucky_core::launch::launcher_log::FILE_NAME);
|
||||
let text = std::fs::read_to_string(&path).expect("launcher.log");
|
||||
assert!(text.contains("[boot] app boot"), "{text}");
|
||||
assert!(text.contains("theme="), "{text}");
|
||||
assert!(text.contains("window="), "{text}");
|
||||
}
|
||||
|
||||
#[path = "tests/export.rs"]
|
||||
mod export;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
|
||||
//!
|
||||
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::report;
|
||||
use crate::app::dialog::export;
|
||||
|
||||
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
|
||||
#[test]
|
||||
fn export_writes_a_bundle_even_without_a_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
|
||||
|
||||
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
|
||||
spec_report.launcher_logs = Some(root.join("logs"));
|
||||
spec_report.session_log = None;
|
||||
spec_report.run_dir = None;
|
||||
spec_report.version_json = None;
|
||||
|
||||
let done = export::export(&spec_report, Lang::ZhCn);
|
||||
let text = done.text.clone();
|
||||
assert!(done.ok, "{text}");
|
||||
assert_eq!(done.truncated, 0);
|
||||
assert!(text.contains("已导出"), "{text}");
|
||||
assert!(text.contains("字节"), "{text}");
|
||||
|
||||
let exports = root.join("logs").join("exports");
|
||||
let files: Vec<_> = std::fs::read_dir(&exports)
|
||||
.expect("导出目录")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "一次导出只该落一个包");
|
||||
let size = files[0].metadata().expect("元数据").len();
|
||||
assert!(size > 0, "包不该是空文件");
|
||||
assert!(
|
||||
text.contains(&size.to_string()),
|
||||
"报出来的字节数要与磁盘一致:{text} / {size}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
|
||||
///
|
||||
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
|
||||
/// 抓不住:那句话排版好不好看(视觉)。
|
||||
#[test]
|
||||
fn a_truncated_export_says_so_in_the_notice() {
|
||||
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
|
||||
|
||||
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
|
||||
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
|
||||
let mut big = String::new();
|
||||
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
|
||||
big.push_str(line);
|
||||
}
|
||||
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
|
||||
|
||||
let mut report = report(false, Vec::new());
|
||||
report.launcher_logs = Some(root.join("logs"));
|
||||
report.run_dir = Some(root.join("game"));
|
||||
report.session_log = None;
|
||||
report.version_json = None;
|
||||
|
||||
let done = export::export(&report, Lang::ZhCn);
|
||||
assert!(done.ok, "{}", done.text);
|
||||
assert_eq!(
|
||||
done.truncated, 1,
|
||||
"这份 latest.log 必须真的被截断(>4 MiB)"
|
||||
);
|
||||
assert!(
|
||||
done.text.contains("已截断"),
|
||||
"界面上没有说「有文件被截断」:{}",
|
||||
done.text
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 模态层的控件树:**遮罩 + 卡片 + 主/次按钮**,正文与路径全部来自 [`super::Modal`]。
|
||||
//!
|
||||
//! ## 命中语义(为什么点遮罩能关、点卡片不关)
|
||||
//!
|
||||
//! `iced::widget::Stack` 的 `update` 从上往下(后画的先收)派发,遇到 `shell.is_event_captured()`
|
||||
//! 立刻停。所以三层的顺序是:
|
||||
//!
|
||||
//! ```text
|
||||
//! stack![ 页面树, 遮罩(mouse_area→Dismiss,整窗), 居中容器(透明)→ opaque(卡片) ]
|
||||
//! ```
|
||||
//!
|
||||
//! 卡片那层用 `opaque` 把卡片内的左键按下**吃掉**([`super::Message`] 不需要再有一条专用
|
||||
//! 消息),于是点卡片里的空白不会冒到遮罩去关框;卡片里的按钮自己会先捕获。
|
||||
//! **`opaque` 只包卡片**是这条语义成立的前提:包住整窗居中容器的话,命中区就变成整窗,
|
||||
//! 遮罩永远收不到按下(r1 复审 P2-1 抓到的原实现)。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Font, Length, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space as Sp;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::components::buttons;
|
||||
use crate::app::components::feedback::dialog::scrim;
|
||||
use crate::app::components::feedback::panel::DialogGeometry;
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::{Message as DialogMessage, Modal};
|
||||
|
||||
/// 卡片内**滚动区**高度(详情 + 路径在这里滚;页脚按钮永远在卡片里)。
|
||||
const SCROLL_HEIGHT: f32 = 300.0;
|
||||
/// 有导出结论时滚动区让出的高度(那一段会多占两行,不让就只能压扁页脚按钮)。
|
||||
const SCROLL_HEIGHT_WITH_NOTICE: f32 = 210.0;
|
||||
/// 卡片最大高(兜底:不许把边框撑出屏幕)。
|
||||
const CARD_HEIGHT: f32 = 660.0;
|
||||
|
||||
/// 把模态层叠在 `base` 上(没有弹窗时由调用方原样返回 `base`,这里是纯叠加)。
|
||||
///
|
||||
/// ## 三层的命中语义(r1 复审 P2-1 的修法)
|
||||
///
|
||||
/// ```text
|
||||
/// stack![ 页面树, 遮罩(mouse_area→Dismiss, 整窗), 居中容器(透明) → opaque(卡片) ]
|
||||
/// ```
|
||||
///
|
||||
/// 关键在于**卡片那层只有卡片那么大**:`opaque(卡片)` 的命中区就是卡片本身,点卡片里
|
||||
/// 只会被它吃掉(`shell.capture_event`),点卡片外则**继续往下走**到遮罩 → `Dismiss`。
|
||||
/// 反例(r1 抓到的原实现):`mouse_area(整窗居中容器).on_press(Swallow)` —— 整窗都在它
|
||||
/// 的 bounds 里,任何一次卡片外点击都先被它捕获,遮罩永远收不到,于是「点遮罩关闭」
|
||||
/// 只存在于注释里。
|
||||
pub(crate) fn layer<'a>(
|
||||
ui: &'a Ui,
|
||||
base: Element<'a, Message>,
|
||||
modal: &'a Modal,
|
||||
) -> Element<'a, Message> {
|
||||
let backdrop = mouse_area(
|
||||
container(Space::new())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(backdrop_color(ui))),
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.on_press(Message::Dialog(DialogMessage::Dismiss));
|
||||
|
||||
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
|
||||
let centered = container(card_plane(ui, modal))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(ui.space(Sp::X6));
|
||||
|
||||
iced::widget::stack![base, backdrop, centered].into()
|
||||
}
|
||||
|
||||
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
|
||||
///
|
||||
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
|
||||
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
|
||||
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
|
||||
///
|
||||
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
|
||||
pub(super) fn backdrop_color(ui: &Ui) -> Color {
|
||||
scrim(ui)
|
||||
}
|
||||
|
||||
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
|
||||
///
|
||||
/// 可见性放宽到 `pub(super)` 只为让 [`super::tests`] 能断言它的 `size_hint`(P2-1 的守卫)。
|
||||
pub(super) fn card_plane<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
iced::widget::opaque(card(ui, modal))
|
||||
}
|
||||
|
||||
/// 卡片本体(**尺寸 = 内容**,不声明 `Fill` —— 见 [`layer`] 的命中语义)。
|
||||
fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
// 几何全部走组件层(r1 复审 P1 的纪律项:别绕过现成的 Dialog 档)。
|
||||
let geometry = DialogGeometry::of(ui);
|
||||
let shape = Component::Dialog.spec().shape;
|
||||
|
||||
// 头(标题 + 三句正文)与脚(按钮)**固定**,中间那段滚——按钮滚出屏幕等于功能不存在
|
||||
// (视觉复审在日志页踩过同一坑:诊断卡把摘要页脚顶出去了)。
|
||||
let mut header = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
header = header.push(role(
|
||||
ui,
|
||||
TypeRole::HeadlineSmall,
|
||||
true,
|
||||
modal.title.as_str(),
|
||||
));
|
||||
for line in &modal.body {
|
||||
header = header.push(wrapped(ui, line.as_str(), ui.color(Role::OnSurface)));
|
||||
}
|
||||
|
||||
let mut middle = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
if let Some(card) = &modal.card {
|
||||
// **详细说明在前、路径在后**:用户先要知道「发生了什么」,再要「文件在哪」。
|
||||
if card.expanded {
|
||||
if let Some(title) = &card.detail_title {
|
||||
middle = middle.push(role(ui, TypeRole::LabelMedium, false, title.as_str()));
|
||||
}
|
||||
for line in &card.detail_lines {
|
||||
middle = middle.push(mono(ui, line.clone()));
|
||||
}
|
||||
middle = middle.push(frame::divider(ui).map(Message::Page));
|
||||
}
|
||||
for (index, (label, path)) in card.paths.iter().enumerate() {
|
||||
middle = middle.push(
|
||||
button(
|
||||
column![
|
||||
role(ui, TypeRole::LabelMedium, false, label.as_str()),
|
||||
mono(ui, path.clone()),
|
||||
]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(0)
|
||||
.style(|_theme, _status| button::Style {
|
||||
background: None,
|
||||
text_color: Color::TRANSPARENT,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
})
|
||||
.on_press(Message::Dialog(DialogMessage::CopyPath(index))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut footer = column![].spacing(ui.space(Sp::X1)).width(Length::Fill);
|
||||
if let Some(notice) = &modal.notice {
|
||||
footer = footer.push(wrapped(ui, notice.as_str(), ui.color(Role::Primary)));
|
||||
}
|
||||
footer = footer.push(actions(ui, modal));
|
||||
|
||||
// 中间那段给**定高**预算:`Shrink` 会让滚动区按内容高排版,把页脚顶到卡片外面
|
||||
// (实测第一版就是这样:导出/关闭两个按钮整排不可见)。
|
||||
let body = if modal.card.is_some() {
|
||||
column![
|
||||
header,
|
||||
scrollable(middle).height(Length::Fixed(if modal.notice.is_some() {
|
||||
SCROLL_HEIGHT_WITH_NOTICE
|
||||
} else {
|
||||
SCROLL_HEIGHT
|
||||
})),
|
||||
frame::divider(ui).map(Message::Page),
|
||||
footer,
|
||||
]
|
||||
} else {
|
||||
column![header, frame::divider(ui).map(Message::Page), footer,]
|
||||
}
|
||||
.spacing(ui.space(Sp::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
|
||||
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
|
||||
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
|
||||
container(body)
|
||||
.width(Length::Fixed(geometry.max_width))
|
||||
.max_height(CARD_HEIGHT)
|
||||
.padding(geometry.padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
|
||||
border: ui.outlined(shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 底部按钮区:**两行**——辅助操作(开文件夹 / 复制路径 / 展开)在上,主次按钮在下靠右。
|
||||
///
|
||||
/// 为什么分两行:五个按钮塞一行在 560 的卡宽里会把主按钮压成竖排(实测第一版就是这样,
|
||||
/// 「导出诊断包」被挤成两行单字)。
|
||||
fn actions<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
let mut column_actions = column![].spacing(ui.space(Sp::X2)).width(Length::Fill);
|
||||
|
||||
if let Some(card) = &modal.card {
|
||||
column_actions = column_actions.push(
|
||||
row![
|
||||
buttons::outlined(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.actions[1].as_str(),
|
||||
Message::Dialog(DialogMessage::OpenFolder),
|
||||
),
|
||||
),
|
||||
buttons::outlined(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.actions[2].as_str(),
|
||||
Message::Dialog(DialogMessage::CopyPaths),
|
||||
),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
card.toggle.as_str(),
|
||||
Message::Dialog(DialogMessage::Toggle),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Sp::X2))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
let mut line = row![Space::new().width(Length::Fill)].spacing(ui.space(Sp::X2));
|
||||
if let Some(secondary) = &modal.secondary {
|
||||
line = line.push(buttons::text(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
secondary.as_str(),
|
||||
Message::Dialog(DialogMessage::Secondary),
|
||||
),
|
||||
));
|
||||
}
|
||||
if !modal.blocking_progress {
|
||||
line = line.push(buttons::filled(
|
||||
ui,
|
||||
buttons::Button::new(
|
||||
modal.primary.as_str(),
|
||||
Message::Dialog(DialogMessage::Primary),
|
||||
),
|
||||
));
|
||||
}
|
||||
column_actions.push(line.align_y(Alignment::Center)).into()
|
||||
}
|
||||
|
||||
/// 令牌排版档的文字(`pages::frame` 的那一套,映射成本层的消息类型)。
|
||||
fn role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
role,
|
||||
emphasized,
|
||||
content,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
.map(Message::Page)
|
||||
}
|
||||
|
||||
/// 等宽文字(路径 / 堆栈 / 捕获输出——这些必须能一眼看出字符边界)。
|
||||
fn mono<'a>(ui: &'a Ui, content: String) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(Font {
|
||||
family: iced::font::Family::Name(ui.families().mono),
|
||||
..Font::DEFAULT
|
||||
})
|
||||
.color(ui.color(Role::OnSurface))
|
||||
.width(Length::Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 正文一段:令牌排版档 + 按词折行(长路径只按词折会把卡片顶破)。
|
||||
fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodyMedium);
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
.width(Length::Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 「日志文件夹」在 app 侧的唯一解析口(M188):路径从哪来、点了按钮之后开哪个目录。
|
||||
//!
|
||||
//! ## 为什么不在页面里现拼路径
|
||||
//!
|
||||
//! 日志根 = `<启动器数据目录>/logs`,而「启动器数据目录」是三模式解析(便携 / 标准 / 自定义)
|
||||
//! 的结果。那条解析链只有一份实现(`core::platform::data_dir` + `presenter::settings_bridge`),
|
||||
//! 页面若自己拼 `exe_dir/.ULL/logs`,标准模式与自定义模式的用户就会看到一个**不存在的目录**。
|
||||
//!
|
||||
//! 本模块只做两件事:
|
||||
//!
|
||||
//! 1. 从设置桥给定的 `config/launcher.json` 反推 `<home>`,再用 `LauncherLayout::logs()` 取分区
|
||||
//! (分区名只从 core 的 `layout` 来,不手拼字符串);
|
||||
//! 2. 打开前先确认目录真的在(不在就建),失败时给出的文本**带完整路径**——用户至少能照着去翻。
|
||||
//!
|
||||
//! ## 谁来登记
|
||||
//!
|
||||
//! `App::new` 拿到设置桥之后调 [`register`]:那是本进程唯一知道「实际生效的数据根」的地方
|
||||
//! (`--data-dir` 也在这里生效)。没登记时(`pages_demo` 出图通道、单测)退回
|
||||
//! `platform::data::layout()`——同一份解析链的缓存版本。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
static LOGS_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
/// `<home>/config/launcher.json` → `<home>/logs`(**只有**这一条形状,别处不许手拼)。
|
||||
pub(crate) fn logs_dir_of(config: &Path) -> Option<PathBuf> {
|
||||
let home = config.parent()?.parent()?;
|
||||
Some(LauncherLayout::new(home).logs())
|
||||
}
|
||||
|
||||
/// 登记本进程的日志目录(`App::new` 在第一帧之前调一次;重复调用以**第一次**为准)。
|
||||
pub(crate) fn register(bridge: &SettingsBridge) {
|
||||
let _ = LOGS_DIR.set(bridge.path().and_then(logs_dir_of));
|
||||
}
|
||||
|
||||
/// 本进程的日志目录。
|
||||
///
|
||||
/// - 已登记且解析出来 ⇒ 那条;
|
||||
/// - 已登记但解析不出来(内存态桥)⇒ `None`:页面照实说「数据根没解析出来」,不猜一个目录;
|
||||
/// - 还没登记(`pages_demo` / 单测)⇒ 退回 `platform::data::layout()`。
|
||||
pub(crate) fn logs_dir() -> Option<PathBuf> {
|
||||
match LOGS_DIR.get() {
|
||||
Some(known) => known.clone(),
|
||||
None => crate::platform::data::layout().map(|layout| layout.logs()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保日志目录存在(不在就建),返回给用户看的路径。
|
||||
///
|
||||
/// 失败文本里**带完整路径**:界面把这段话原样显示出来,用户能复制去自己找。
|
||||
pub(crate) fn prepare(dir: &Path) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|error| format!("{}(这个目录建不出来:{error})", dir.display()))?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{}(这个路径不是一个目录)", dir.display()));
|
||||
}
|
||||
Ok(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// 打开日志文件夹:解析 → 确保存在 → 交给平台打开器。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 返回的文本永远带路径(`...logs` 那一串),可以直接贴给用户/贴进 issue。
|
||||
pub(crate) fn open() -> Result<PathBuf, String> {
|
||||
let dir = logs_dir().ok_or_else(|| {
|
||||
"日志目录:数据根没解析出来(启动器拿不到自己所在的目录;用 --data-dir 指定一个)"
|
||||
.to_string()
|
||||
})?;
|
||||
let dir = prepare(&dir)?;
|
||||
crate::app::dialog::export::open_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 落点形状:`<home>/config/launcher.json` → `<home>/logs`。
|
||||
///
|
||||
/// 变异证明:把 `logs_dir_of` 里的 `LauncherLayout::logs` 换成 `cache`,这条立刻红。
|
||||
#[test]
|
||||
fn the_log_folder_is_the_logs_partition_next_to_config() {
|
||||
let config = PathBuf::from("/tmp/ull-home/.ULL/config/launcher.json");
|
||||
assert_eq!(
|
||||
logs_dir_of(&config),
|
||||
Some(PathBuf::from("/tmp/ull-home/.ULL/logs"))
|
||||
);
|
||||
// 少了 config 那一层就没有 `<home>` 可言:宁可不猜。
|
||||
assert_eq!(logs_dir_of(Path::new("launcher.json")), None);
|
||||
}
|
||||
|
||||
/// **目录真的存在**(守门③):`prepare` 在不在时把它建出来,并且返回同一个路径。
|
||||
///
|
||||
/// 变异证明:删掉 `create_dir_all` 那一行,`is_dir` 立刻为假。
|
||||
#[test]
|
||||
fn prepare_creates_the_folder_and_reports_the_same_path() {
|
||||
let root = std::env::temp_dir().join(format!("ull-logs-folder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join(".ULL/logs");
|
||||
assert!(!dir.is_dir(), "夹具目录不该已经存在");
|
||||
let prepared = prepare(&dir).expect("应当建得出来");
|
||||
assert_eq!(prepared, dir);
|
||||
assert!(dir.is_dir(), "打开之前必须真的存在");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 失败文本带路径(「不许只在成功时可用」)。
|
||||
#[test]
|
||||
fn a_failure_names_the_path_it_could_not_use() {
|
||||
let file = std::env::temp_dir().join(format!("ull-logs-notadir-{}", std::process::id()));
|
||||
std::fs::write(&file, b"x").expect("写夹具");
|
||||
let error = prepare(&file).expect_err("一个文件不是目录");
|
||||
assert!(error.contains(&file.display().to_string()), "{error}");
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 应用消息:单一大枚举。
|
||||
//!
|
||||
//! 纪律:本枚举是**唯一**的消息类型,`view` 产出的所有回调都必须落到这里;组件库与页面层
|
||||
//! 各自用自己的 `Message`(组件契约 §2 的泛型纪律),由 `view` 做一次 `.map` 接上。
|
||||
//!
|
||||
//! ## 三类输入(M90)
|
||||
//!
|
||||
//! | 来源 | 变体 | 落点 |
|
||||
//! |---|---|---|
|
||||
//! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 |
|
||||
//! | **时间** | `Frame`(忙碌,33 ms 闸门)+ `Heartbeat`(空闲,1 Hz) | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` |
|
||||
//! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` |
|
||||
|
||||
use iced::{window, window::Direction};
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use crate::pages;
|
||||
use crate::shell::NavItem;
|
||||
|
||||
use super::components;
|
||||
|
||||
/// 消息(单一大枚举)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// 启动即发一次(`iced::application` 的 boot 闭包)。
|
||||
Boot,
|
||||
/// `window::latest()` 的结果。
|
||||
WindowReady(Option<window::Id>),
|
||||
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
|
||||
SystemTheme(iced::theme::Mode),
|
||||
/// 一帧画完。**只在需要 33 ms 节拍时订阅**:截图通道 / 有 Busy-Running 实例 / 有未过期
|
||||
/// toast / 派发后的 3 s 观察窗(见 `subscription::needs_frames`)。
|
||||
Frame,
|
||||
/// **空闲态**的 1 Hz 心跳:`Task::perform(sleep(1s))` 的自续链,用来替代空闲时的帧循环
|
||||
/// (M90 r3 / P2-2)。它保证 `theme::config_poll_due` 仍按「30 × 33 ms ≈ 1 秒」的
|
||||
/// presenter 语义跑,外部 CLI/AI 改配置后主题 1 秒内生效。
|
||||
Heartbeat,
|
||||
/// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。
|
||||
Window(window::Event),
|
||||
/// 窗口句柄(M93:`window::run` 探测出的 raw handle;`None` = 拿不到,整窗 α 无法下发)。
|
||||
WindowHandle(Option<isize>),
|
||||
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
|
||||
Page(pages::Message),
|
||||
/// **AI 助手栏 / 独立页**的消息(M104:三档状态、输入框、拖拽、键盘)。
|
||||
///
|
||||
/// 为什么不走 `pages::Message`:助手栏是**外壳级视图**(它挂在任何页面右边),不是某一页的
|
||||
/// 消息;塞进页面枚举还要动 `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的
|
||||
/// M101 改),绕开它就少一处 rebase 面。落点是 `app/update/agent.rs`。
|
||||
Agent(crate::shell::agent::Message),
|
||||
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
|
||||
///
|
||||
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
|
||||
/// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。
|
||||
Select(NavItem),
|
||||
/// 按下标题栏 → 拖窗。
|
||||
DragWindow,
|
||||
/// 边缘热区:开始缩放(方向由坐标决定)。
|
||||
ResizeStart(Direction),
|
||||
/// 最小化。
|
||||
Minimize,
|
||||
/// 最大化/还原。
|
||||
ToggleMaximize,
|
||||
/// 关窗退出。
|
||||
Close,
|
||||
/// 截图回来了。
|
||||
Captured(window::Screenshot),
|
||||
/// gallery(组件库 harness)里的演示交互。
|
||||
Demo(components::gallery::DemoMessage),
|
||||
/// 模态对话框(M187):主/次按钮、遮罩、展开、导出、打开文件夹、复制路径。
|
||||
Dialog(super::dialog::Message),
|
||||
}
|
||||
|
||||
/// 外壳导航项 → 路由(导航项只在一级页之间有对应;AI 项对应 [`Route::Agent`])。
|
||||
///
|
||||
/// 这是 `NavItem → Route` 的**唯一**转换点:外壳(`src/shell/nav.rs`)与页面导航轨
|
||||
/// (`pages::NAV`)最终都落到同一个 `Route`,所以两条入口不会各走一套页面语义。
|
||||
pub(super) fn route_of(nav: NavItem) -> Route {
|
||||
match nav {
|
||||
NavItem::Instances => Route::Instances,
|
||||
NavItem::Download => Route::Download,
|
||||
NavItem::Logs => Route::Logs,
|
||||
NavItem::Settings => Route::Settings,
|
||||
NavItem::Ai => Route::Agent,
|
||||
// 轨底头像位(M114):账户是一级页,但**不在主序列**里
|
||||
NavItem::Account => Route::Accounts,
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由 → 外壳导航项([`route_of`] 的逆;外壳 rail 与 caption 键都按它高亮)。
|
||||
///
|
||||
/// `Route::Detail(_)` 高亮「实例」(与 `Route::nav()` 同源)。
|
||||
pub(super) fn nav_item_of(route: &Route) -> NavItem {
|
||||
match route {
|
||||
Route::Instances | Route::Detail(_) => NavItem::Instances,
|
||||
// M109:内容库与版本管理共用「下载」那一个导航项(B1 裁定)
|
||||
Route::Download | Route::Content => NavItem::Download,
|
||||
Route::Logs => NavItem::Logs,
|
||||
Route::Settings => NavItem::Settings,
|
||||
Route::Agent => NavItem::Ai,
|
||||
Route::Accounts => NavItem::Account,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线,以及启动序列。
|
||||
//!
|
||||
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
|
||||
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
|
||||
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
|
||||
//!
|
||||
//! ## 为什么拆成目录(M90)
|
||||
//!
|
||||
//! 本文件原来是**单个 400 行的 `app.rs`**:正好卡在「单文件 ≤400 行、动它前必须先拆」的硬门槛上
|
||||
//! (AGENTS 的文件上限 + 本 mission 的第一条任务)。拆法与职责:
|
||||
//!
|
||||
//! | 文件 | 职责(**只做这一件事**) |
|
||||
//! |---|---|
|
||||
//! | [`state`] | [`App`] 字段 + 构造(presenter 三件在此持有)+ 主题切换 |
|
||||
//! | [`message`] | [`Message`] 枚举与「导航项 → 路由」的翻译 |
|
||||
//! | [`update`] | 全部 `Message` 分支 + 「用户动作 → `AppCommand`」的派发与事件回流 |
|
||||
//! | [`subscription`] | 帧事件(截图)与 presenter 的 TICK 节拍 |
|
||||
//! | [`view`] | `view` / `theme` / `title` |
|
||||
//! | [`boot`] | [`run`]:后端探测 → 字体 → `iced::application(..)` |
|
||||
//!
|
||||
//! 拆分的每一步都保持**可观察行为不变**(截图四件套 `--screenshot` / `--screenshot-theme` /
|
||||
//! `--screen=gallery` / `--diff` 出图逐字节相同),presenter 接线是在拆分**之后**另一步做的。
|
||||
//!
|
||||
//! ## 组件库为什么还在本模块下声明
|
||||
//!
|
||||
//! `src/lib.rs` 是五条组件线共用的文件,改它必然冲突;`#[path]` 保证物理路径仍是
|
||||
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
|
||||
//! (组件源码里的文档与测试都按这个路径写)。
|
||||
|
||||
mod appearance;
|
||||
mod backend;
|
||||
mod boot;
|
||||
pub(crate) mod diag;
|
||||
mod dialog;
|
||||
pub(crate) mod logs_folder;
|
||||
mod message;
|
||||
mod state;
|
||||
mod subscription;
|
||||
mod update;
|
||||
mod view;
|
||||
mod window_alpha;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use boot::run;
|
||||
pub use message::Message;
|
||||
|
||||
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
|
||||
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
|
||||
//
|
||||
// M90:可见性从 `mod` 放到 `pub(crate) mod` —— 页面层(`src/pages/**`)要**用**真组件
|
||||
// (`components::buttons` / `navigation` / `divider`),而 `app` 不是它的父模块。
|
||||
// 只放开 crate 内可见,组件目录的归属规则一字不改。
|
||||
#[path = "../components/mod.rs"]
|
||||
pub(crate) mod components;
|
||||
|
||||
/// 验收窗口尺寸(1180×760 @100% DPI —— D30 的硬要求)。
|
||||
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||
width: 1180.0,
|
||||
height: 760.0,
|
||||
};
|
||||
|
||||
/// 产品名(自绘标题栏的标题;`'static` 好让 view 借用)。
|
||||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||
|
||||
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
const MIN_SIZE: iced::Size = iced::Size {
|
||||
width: 880.0,
|
||||
height: 560.0,
|
||||
};
|
||||
|
||||
/// 截图落盘的帧号(见模块文档)。
|
||||
const CAPTURE_AT_FRAME: u32 = 3;
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。
|
||||
//!
|
||||
//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4)
|
||||
//!
|
||||
//! | 字段 | 谁给 | 干什么 |
|
||||
//! |---|---|---|
|
||||
//! | `backend: Box<dyn Backend>` | [`build_backend`](unlucky_presenter::backend::build_backend) | 派发 [`AppCommand`](unlucky_presenter::dto::AppCommand)、收 [`AppEvent`](unlucky_presenter::dto::AppEvent) |
|
||||
//! | `bridge: SettingsBridge` | [`SettingsBridge::open`](unlucky_presenter::settings_bridge::SettingsBridge::open) | 读一次外观/主题,之后每 N 拍问「文件变了吗」 |
|
||||
//! | `state: AppState` | [`AppState::new`](unlucky_presenter::state::AppState::new) | **唯一**的状态写入口(`apply` / `navigate`) |
|
||||
//!
|
||||
//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西。
|
||||
//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建——
|
||||
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
|
||||
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
|
||||
//!
|
||||
//! ## 本文件只管「有哪些字段」
|
||||
//!
|
||||
//! M119 起按功能拆出去(`state.rs` 当时正好 400 行、零余量):
|
||||
//!
|
||||
//! | 子文件 | 内容 |
|
||||
//! |---|---|
|
||||
//! | [`construct`] | 两个构造器 + 字段初始化表 |
|
||||
//! | [`refresh`] | [`App::refresh_view`](快照 → 视图模型的唯一转换点) |
|
||||
//! | [`theme`] | `Ui` 重算 / 主题档 / [`App::has_screenshot`] |
|
||||
//! | [`boot_probe`] | 启动探针(`ULL_*` 取证开关与首帧日志) |
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::window;
|
||||
|
||||
use unlucky_presenter::dto::Backend;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::{Families, Ui};
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。
|
||||
///
|
||||
/// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、
|
||||
/// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。
|
||||
pub(super) const TICK_MS: u64 = 33;
|
||||
|
||||
/// [`TICK_MS`] 的 `Duration` 形态。
|
||||
pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TICK_MS);
|
||||
|
||||
/// **空闲态**的低频心跳(1 Hz,M90 r3 / P2-2)。
|
||||
///
|
||||
/// 空闲时不能订阅 `window::frames()`:那条订阅会自持重绘循环(实测软件渲染约 500 帧/秒),
|
||||
/// 而空闲时唯一需要的时钟是「偶发一次」——外部 CLI / AI 工具改 `launcher.json` 后的热切
|
||||
/// (presenter 的 `theme::config_poll_due` 语义是 30 × 33 ms ≈ 1 秒一拍)。
|
||||
///
|
||||
/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒
|
||||
/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`:
|
||||
/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。
|
||||
///
|
||||
/// **M192 的第三条时钟不在这里**:动效(`components::motion`)也只在「有轨道」时借用帧订阅,
|
||||
/// 最后一个轨道到期就清空 ⇒ 下一拍 [`App::needs_frames`] 自动退回这条 1 Hz 心跳。
|
||||
pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。
|
||||
///
|
||||
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
|
||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
mod boot_probe;
|
||||
mod construct;
|
||||
mod refresh;
|
||||
mod theme;
|
||||
|
||||
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
|
||||
///
|
||||
/// 为什么用 `#[path]` 而不是在 `lib.rs` 加一行 `mod presenter;`:本 mission 的 scope 不含
|
||||
/// `lib.rs` / `app/mod.rs`,而 `shell::agent` 挂 `pages/agent/` 是同一个先例。没有第二份实现。
|
||||
#[path = "../presenter/mod.rs"]
|
||||
pub(super) mod presenter;
|
||||
|
||||
pub(crate) use boot_probe::{log_first_frame, STARTED};
|
||||
|
||||
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
|
||||
pub struct App {
|
||||
/// 启动参数(截图/主题/画面开关)。
|
||||
pub(super) options: Options,
|
||||
/// 字体族(切主题时重建 [`Ui`] 要用)。
|
||||
pub(super) families: Families,
|
||||
/// 令牌求值器(浅/深两套)。
|
||||
pub(super) ui: Ui,
|
||||
/// 后端通道(真 host 或 mock)。
|
||||
pub(super) backend: Box<dyn Backend>,
|
||||
/// 设置持久化桥。
|
||||
pub(super) bridge: SettingsBridge,
|
||||
/// presenter 的全局状态(唯一写入口是 `apply`)。
|
||||
pub(super) state: AppState,
|
||||
/// 已推进的拍数(`theme::config_poll_due` 的输入)。单位是 **33 ms 一拍**,不是调用次数。
|
||||
pub(super) ticks: u64,
|
||||
/// 下一次「比对配置文件 mtime」的 tick 号(见 `update::advance` 的注释)。
|
||||
pub(super) config_due_at: u64,
|
||||
/// 设置文件里的主题模式(`auto` / `light` / `dark`)。
|
||||
///
|
||||
/// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件
|
||||
/// 盖掉配置文件的显式主题(M90 r3 修)。
|
||||
pub(super) theme_mode: ThemeMode,
|
||||
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
|
||||
///
|
||||
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
|
||||
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
|
||||
pub(super) lang: LangState,
|
||||
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
|
||||
pub(super) last_tick: Instant,
|
||||
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
|
||||
pub(super) width: f32,
|
||||
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
|
||||
pub(super) view: pages::Model,
|
||||
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
|
||||
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
|
||||
pub(super) view_dirty: bool,
|
||||
/// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。
|
||||
///
|
||||
/// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让
|
||||
/// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。
|
||||
///
|
||||
/// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍,
|
||||
/// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。
|
||||
pub(super) watch_until: Option<Instant>,
|
||||
/// 窗口句柄(`window::latest()` 之后才有)。
|
||||
pub(super) window: Option<window::Id>,
|
||||
/// 已画的帧数(截图闸门用)。
|
||||
pub(super) frames: u32,
|
||||
/// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。
|
||||
pub(super) refreshes: u64,
|
||||
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
|
||||
pub(super) auto_selected: bool,
|
||||
/// 上一次看到的**中文字体下载状态**(M168)。
|
||||
///
|
||||
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
|
||||
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
|
||||
/// 而空闲态不会为了它每拍白重建一次。
|
||||
pub(super) font_seen: crate::fonts::install::CjkInstall,
|
||||
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
|
||||
pub(super) height: f32,
|
||||
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
|
||||
pub(super) appearance: pages::appearance::State,
|
||||
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
|
||||
/// 强调色这类本 mission 不管的字段原样保留)。
|
||||
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
|
||||
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
|
||||
pub(super) hwnd: Option<isize>,
|
||||
/// 设置页各组自己的运行期状态(M101 r2 的**状态漏斗**)。
|
||||
///
|
||||
/// 所有者在 `pages/settings/state.rs` 的 `Groups`:加/改某一组的字段只改那一组自己的文件,
|
||||
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
|
||||
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
|
||||
pub(super) settings: pages::settings::Groups,
|
||||
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 草稿/焦点。
|
||||
///
|
||||
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
|
||||
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
|
||||
///
|
||||
/// **只留 UI 态**:会话内容 / 未读 / busy 在 `agent_host` 的 rail 上(M119 起)。
|
||||
pub(super) agent: crate::shell::agent::AgentUi,
|
||||
/// AI 助手的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。
|
||||
///
|
||||
/// 为什么单独一份 `AppState` 而不是把 `state` 交给它:`Presenter` 的可写状态后门
|
||||
/// (`state_mut`)只在 presenter crate 的 `#[cfg(test)]` 里存在,而 `app/subscription.rs`
|
||||
/// 等处仍要直读 `App.state`。所以口径是——**`App.state` 管除 rail 外的一切,这个句柄管 rail**;
|
||||
/// `AppEvent::Agent` 只喂这里(`App.state.ai` 从此不再被读,也不再被写)。
|
||||
pub(super) agent_host: presenter::AgentHost,
|
||||
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
|
||||
///
|
||||
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
|
||||
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
|
||||
/// 是同一个理由、同一个做法。
|
||||
pub(super) download_draft: pages::InstallDraft,
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
|
||||
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
|
||||
pub(super) content: pages::content::PageState,
|
||||
/// 账户页的**表单状态**(三条轨道的输入串;密码只活在这一次请求里)。
|
||||
pub(super) account_form: pages::account::Form,
|
||||
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
|
||||
pub(super) account: pages::account::Page,
|
||||
/// 实例页「启动参数」区的表单(M157):五项 + 脏标记 + 绑定的实例 id。
|
||||
///
|
||||
/// 住在 `App` 的理由与 `download_draft` 完全一致:`refresh_view` 每次都整棵重建
|
||||
/// `pages::Model`,用户正在输入的内容存不到视图模型里。重播种点在
|
||||
/// [`App::refresh_view`](`LaunchForm::sync`,换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
pub(super) launch_form: pages::LaunchForm,
|
||||
/// 启动失败弹窗的**事实**(M187):与 `state.dialogs` 里的当前条按 id 绑定。
|
||||
///
|
||||
/// 为什么事实不塞进 `DialogRequest`:那是 presenter 的公共 DTO,弹窗与「这次失败的现场」
|
||||
/// 是两件事——队列管弹窗的排队与按钮,卡片管证据(路径 / 崩溃摘要 / 捕获输出)。
|
||||
pub(super) dialog_card: Option<super::dialog::CrashCard>,
|
||||
/// 弹窗的**视图模型**(在 [`App::refresh_view`] 里重建;`view()` 只借它)。
|
||||
///
|
||||
/// 为什么不像页面那样现算:`Element` 借字符串,现算的模型是局部变量,`view()` 一返回就
|
||||
/// 悬空(编译期就过不去)。与 `view` / `account` 两张缓存同一条理由、同一个门控。
|
||||
pub(super) dialog_model: Option<super::dialog::Modal>,
|
||||
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
|
||||
pub(super) avatars: pages::account::avatar::Cache,
|
||||
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
|
||||
///
|
||||
/// `None` = 还没下发过(首屏 `boot` 那一次)。指纹类型见
|
||||
/// [`crate::platform::win32::corner::CornerApplied`]。
|
||||
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// iced 侧的**设置组快照**(M201):presenter 的 `CoreEvent::SettingsChanged` 回流进来的那一份。
|
||||
///
|
||||
/// 真值只有一份——presenter 的 `AppState::settings_groups`(`apply_core` 是它唯一的写入口,
|
||||
/// 由 host 的 `refresh_all` / `SetSetting` 分支发出来)。这里**不另存一个字段**:那会变成第二份
|
||||
/// 真相,两份在 `pump` 之间必然有一步不一致(而「界面上是旧值、盘上是新值」正是本 mission 要
|
||||
/// 消灭的形状)。要画的那一份是 [`pages::Model::settings`](`refresh_view` 里从同一个字段克隆)。
|
||||
///
|
||||
/// `#[cfg(test)]`:产品路径不需要这个入口(页面拿的是 `Model.settings`,同一份数据的投影),
|
||||
/// 断言需要它——「App 认的值变了吗」必须能直接从 App 上问,不能去问页面模型(那是下游)。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn settings_snapshot(&self) -> &[unlucky_presenter::dto::SettingGroupView] {
|
||||
&self.state.settings_groups
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! 启动期取证:后端选项的换算 + 首帧耗时那一行日志。
|
||||
//!
|
||||
//! 从 `app/state.rs` 拆出(rebase 到 5adac23 之后 `state.rs` 顶到 401 行)。这两件事都属于
|
||||
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
|
||||
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::cli::Options;
|
||||
use unlucky_presenter::backend::AppOptions;
|
||||
|
||||
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
|
||||
pub(crate) static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// 首帧日志只打一次。
|
||||
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 启动参数 → presenter 的后端选项。
|
||||
pub(super) fn app_options(options: &Options) -> AppOptions {
|
||||
AppOptions {
|
||||
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
|
||||
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
|
||||
data_root: options.data_dir.clone(),
|
||||
mock: options.screenshot.is_some() && !real_host_in_screenshot(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_SCREENSHOT_REAL_HOST=1`:让**截图通道**接真 host(默认关)。
|
||||
///
|
||||
/// ## 为什么需要它(M201)
|
||||
///
|
||||
/// 截图通道恒 `mock: true`(D30:同一份代码出同一张图),而 `MockBackend` **不发**
|
||||
/// `CoreEvent::SettingsChanged` ⇒ 设置页拿不到任何真值,只能拍演示行。可「设置页里改了就生效」
|
||||
/// 这件事的正脸图**必须带真值**:M193 用户的投诉就是「图上有 8 threads,追下去是假的」。
|
||||
/// 这条开关把出图的**数据源**换成真 host(`--data-dir` 指一个临时根),渲染通道与落盘一字不改。
|
||||
///
|
||||
/// ## 边界(别把它当产品行为)
|
||||
///
|
||||
/// - 默认关;开了它就不再满足 D30 的可复现(数据来自磁盘,两台机器会不同),所以**只用于取证**;
|
||||
/// - 它只改 `mock` 这一位,不碰任何业务分支(`build_backend` 的其余两条路仍然照旧);
|
||||
/// - 与 `ULL_START_ROUTE` / `ULL_SETTINGS_GROUP` 同一族:只影响这一次进程。
|
||||
fn real_host_in_screenshot() -> bool {
|
||||
std::env::var("ULL_SCREENSHOT_REAL_HOST")
|
||||
.ok()
|
||||
.is_some_and(|raw| raw.trim() == "1")
|
||||
}
|
||||
|
||||
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
|
||||
pub(crate) fn log_first_frame() {
|
||||
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(started) = STARTED.get() {
|
||||
diag!(
|
||||
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! `App` 的两个构造器(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
|
||||
//!
|
||||
//! | 构造器 | 谁用 | 碰不碰磁盘 |
|
||||
//! |---|---|---|
|
||||
//! | [`App::new`](super::App::new) | 生产(`main` → `boot`) | 按启动参数选后端 + 打开设置桥 |
|
||||
//! | [`App::with_presenter`](super::App::with_presenter) | 单测(后端与桥都由调用方给) | **不碰** |
|
||||
//!
|
||||
//! 两个都走 `with_presenter`:生产路径只是先造好后端与桥。**字段初始化表**也在这一个文件里
|
||||
//! (构造 = 字段表 + 三件外部依赖),加字段时只改这一处。
|
||||
|
||||
use iced::window;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::backend::{build_backend, config_path};
|
||||
use unlucky_presenter::dto::Backend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::{Families, Ui};
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
use super::super::WINDOW_SIZE;
|
||||
use super::boot_probe::app_options;
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// 生产构造:按启动参数选后端、打开设置桥。
|
||||
///
|
||||
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
|
||||
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
|
||||
/// [`build_backend`] 自己退回 mock)。
|
||||
pub(in crate::app) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||
let backend = build_backend(&app_options(&options));
|
||||
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
|
||||
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
|
||||
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
|
||||
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
|
||||
let bridge = match &options.data_dir {
|
||||
Some(root) => SettingsBridge::with_path(config_path(root)),
|
||||
None => SettingsBridge::open(),
|
||||
};
|
||||
// M188:日志目录也在这一拍登记——它是从**同一份**设置桥反推的(`--data-dir` 同样生效),
|
||||
// 所以设置页显示的路径与 `launcher.log` 的真实落点不会各说各话。
|
||||
crate::app::logs_folder::register(&bridge);
|
||||
Self::with_presenter(options, families, flavor, backend, bridge)
|
||||
}
|
||||
|
||||
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
|
||||
pub(in crate::app) fn with_presenter(
|
||||
options: Options,
|
||||
families: Families,
|
||||
flavor: SchemeFlavor,
|
||||
backend: Box<dyn Backend>,
|
||||
bridge: SettingsBridge,
|
||||
) -> Self {
|
||||
let width = WINDOW_SIZE.width;
|
||||
let height = WINDOW_SIZE.height;
|
||||
let theme_mode = bridge.load().1;
|
||||
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
|
||||
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
|
||||
//
|
||||
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
|
||||
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
|
||||
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
|
||||
(
|
||||
unlucky_presenter::prefs::AppearancePrefs::factory(),
|
||||
unlucky_presenter::prefs::AppearanceBase::factory(),
|
||||
)
|
||||
} else {
|
||||
(bridge.load().0, bridge.load_base())
|
||||
};
|
||||
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
|
||||
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
|
||||
let backend_tier = super::super::backend::effective_backend(options.backend);
|
||||
let mut appearance = pages::appearance::State::new(
|
||||
&appearance_prefs,
|
||||
&appearance_base,
|
||||
backend_tier,
|
||||
flavor,
|
||||
);
|
||||
// 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
|
||||
if let Some(index) =
|
||||
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
|
||||
{
|
||||
appearance.selected_group = index;
|
||||
}
|
||||
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
|
||||
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
|
||||
let lang = LangState::boot(&bridge, options.screenshot.is_some());
|
||||
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
|
||||
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
|
||||
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
|
||||
crate::fonts::install::set_offline(options.screenshot.is_some());
|
||||
let mut view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
height,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
view.lang = lang.lang();
|
||||
|
||||
let mut app = Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
families,
|
||||
options,
|
||||
backend,
|
||||
bridge,
|
||||
state: AppState::new(),
|
||||
ticks: 0,
|
||||
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
|
||||
theme_mode,
|
||||
lang,
|
||||
last_tick: std::time::Instant::now(),
|
||||
width,
|
||||
view,
|
||||
view_dirty: false,
|
||||
watch_until: None,
|
||||
window: None::<window::Id>,
|
||||
frames: 0,
|
||||
refreshes: 0,
|
||||
auto_selected: false,
|
||||
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
|
||||
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
|
||||
font_seen: crate::fonts::install::status(),
|
||||
height,
|
||||
appearance,
|
||||
appearance_prefs,
|
||||
hwnd: None,
|
||||
settings: pages::settings::Groups::default(),
|
||||
// 出厂是**空会话**(M119 删了 M104 的样本会话);取证开关 `ULL_AGENT_*` 的档位
|
||||
// 与夹具在 `boot_agent` 里落地(它要先让 presenter 知道栏可见性,见那里)。
|
||||
agent: crate::shell::agent::AgentUi::new(),
|
||||
agent_host: super::presenter::AgentHost::from_env(),
|
||||
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
|
||||
download_draft: pages::InstallDraft::default(),
|
||||
launch_form: pages::LaunchForm::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
dialog_card: None,
|
||||
dialog_model: None,
|
||||
content: pages::content::PageState::default(),
|
||||
account_form: pages::account::Form::new(),
|
||||
account: pages::account::Page::build(
|
||||
&AppState::new(),
|
||||
&pages::account::Form::new(),
|
||||
&mut pages::account::avatar::Cache::default(),
|
||||
unlucky_i18n::Lang::En,
|
||||
width,
|
||||
),
|
||||
avatars: pages::account::avatar::Cache::default(),
|
||||
corner_applied: None,
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||
app.hydrate_ai_settings();
|
||||
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
||||
app.hydrate_java_settings();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
app.lang.log_cjk();
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! 快照 → 视图模型的重建口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
|
||||
//!
|
||||
//! 全仓只有 [`App::refresh_view`] 这一个「presenter 快照 → 页面要画的东西」的转换点,
|
||||
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
|
||||
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
|
||||
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
|
||||
///
|
||||
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
|
||||
pub(in crate::app) fn refresh_view(&mut self) {
|
||||
// **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。
|
||||
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
|
||||
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
|
||||
self.sync_agent_route();
|
||||
// M119:把「栏可见吗」写回 presenter(未读口径的输入,两个窄入口:`navigate` + `prefs_mut`)。
|
||||
// 必须在 `sync_agent_route` 之后:档位刚被它对齐过,这里读到的才是这一帧的真值。
|
||||
self.sync_agent_presenter();
|
||||
|
||||
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
|
||||
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
|
||||
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
|
||||
if self.state.install_cancelled != self.install_cancelled_seen {
|
||||
self.install_cancelled_seen = self.state.install_cancelled;
|
||||
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文,
|
||||
// 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。
|
||||
let text = pages::cancelled_toast(self.lang());
|
||||
self.state.toasts.push(ToastKind::Info, text);
|
||||
}
|
||||
|
||||
// M161:启动收尾的提示语也在这里弹(presenter 只发结构:结局 + 退出码 + 失败阶段)。
|
||||
// 此前 presenter 直接推一条自己拼好的中文 Toast,英文界面会弹中文。
|
||||
if self.state.launch_ends != self.launch_ends_seen {
|
||||
self.launch_ends_seen = self.state.launch_ends;
|
||||
if let Some(finished) = self.state.last_launch_end.as_deref() {
|
||||
let text = view::instances::launch_toast(finished, self.lang());
|
||||
let kind = if finished.outcome == unlucky_presenter::dto::LogOutcome::Ok
|
||||
&& finished.failure.is_none()
|
||||
{
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
};
|
||||
self.state.toasts.push(kind, text);
|
||||
// M187:失败要**弹窗**(Toast 会自己消失,崩溃现场不能只靠它)。
|
||||
let ended = finished.clone();
|
||||
self.open_failure_dialog(&ended);
|
||||
}
|
||||
}
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
|
||||
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
|
||||
let draft = self.download_draft.clone();
|
||||
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
|
||||
let width = self.agent.content_width(self.width);
|
||||
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
|
||||
self.view = super::super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.height,
|
||||
self.options.force_state,
|
||||
category,
|
||||
&draft,
|
||||
self.lang(),
|
||||
&self.content,
|
||||
);
|
||||
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
self.sync_launch_form();
|
||||
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
|
||||
// 皮肤解码走缓存,所以重复重建不重解图)。
|
||||
self.rebuild_account_page(width);
|
||||
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
|
||||
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
|
||||
self.dialog_model = self.dialog_modal();
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
/// 把详情页当前实例的启动参数快照灌进表单(唯一的重播种点,见 `pages/instances/state.rs`)。
|
||||
///
|
||||
/// 不在详情页(或实例已不在快照里)时解绑:下次进详情页会重新播种,
|
||||
/// 免得「A 的草稿被当成 B 的初值」。
|
||||
fn sync_launch_form(&mut self) {
|
||||
let Route::Detail(id) = self.state.route() else {
|
||||
self.launch_form.bound = None;
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.state
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
{
|
||||
Some(instance) => {
|
||||
let view = instance.launch.clone();
|
||||
self.launch_form.sync(&view);
|
||||
}
|
||||
None => self.launch_form.bound = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 主题与 `Ui` 的重算口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
|
||||
//!
|
||||
//! 三件事在这里:①`Ui` 什么时候重建([`App::rebuild_ui`])②主题档怎么定
|
||||
//! ([`App::set_flavor`] / [`App::apply_config_theme`])③读当前求值器([`App::ui`])。
|
||||
//!
|
||||
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
/// 本次进程是否走截图通道。
|
||||
pub(in crate::app) fn has_screenshot(&self) -> bool {
|
||||
self.options.screenshot.is_some()
|
||||
}
|
||||
|
||||
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||||
///
|
||||
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
|
||||
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
|
||||
pub(in crate::app) fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||||
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
|
||||
return;
|
||||
}
|
||||
|
||||
self.appearance.set_flavor(flavor);
|
||||
let previous = self.ui.name();
|
||||
self.rebuild_ui();
|
||||
diag!(
|
||||
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||||
previous,
|
||||
self.ui.name()
|
||||
);
|
||||
}
|
||||
|
||||
/// 按「配色来源是否取色成功」重建 [`Ui`]。
|
||||
///
|
||||
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
|
||||
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
|
||||
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
|
||||
pub(in crate::app) fn rebuild_ui(&mut self) {
|
||||
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
|
||||
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
|
||||
let panel_alpha = self.appearance.effective_panel_alpha();
|
||||
self.ui = match self.appearance.derived_scheme() {
|
||||
Some(scheme) => {
|
||||
diag!(
|
||||
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
|
||||
self.appearance.palette_level()
|
||||
);
|
||||
Ui::from_scheme(*scheme, self.families)
|
||||
}
|
||||
None => Ui::new(self.appearance.flavor(), self.families),
|
||||
}
|
||||
.with_panel_alpha(panel_alpha);
|
||||
}
|
||||
|
||||
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
|
||||
///
|
||||
/// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor`
|
||||
/// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。
|
||||
///
|
||||
/// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在
|
||||
/// `Message::SystemTheme` 里落地)。
|
||||
pub(in crate::app) fn apply_config_theme(&mut self) {
|
||||
if self.has_screenshot() || self.options.theme.is_some() {
|
||||
return;
|
||||
}
|
||||
let mode = self.bridge.load().1;
|
||||
self.theme_mode = mode;
|
||||
match mode {
|
||||
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
|
||||
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
|
||||
ThemeMode::Auto => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前令牌求值器(测试与截图日志用)。
|
||||
pub(in crate::app) fn ui(&self) -> &Ui {
|
||||
&self.ui
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。
|
||||
//!
|
||||
//! ## 为什么要「按需」
|
||||
//!
|
||||
//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是
|
||||
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
|
||||
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
|
||||
//!
|
||||
//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]):
|
||||
//!
|
||||
//! | 条件 | 为什么需要 33 ms 节拍 |
|
||||
//! |---|---|
|
||||
//! | 截图通道 | 第 3 帧要落盘 |
|
||||
//! | **有动画在跑**(M192) | 动效时间轴要逐帧采样(帧泵自己按 16 ms 结算,见 `components::motion`) |
|
||||
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
|
||||
//! | 有未过期的 toast | toast 按毫秒倒计时 |
|
||||
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
|
||||
//!
|
||||
//! 上面五条**共享同一个布尔**:订阅表里 `window::frames()` 只出现一处(写在同一个 `if` 里),
|
||||
//! 所以「动画」与「观察窗 / Busy」同时成立时也只有一条订阅——不双订阅靠的是结构,不是判重。
|
||||
//!
|
||||
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
|
||||
//!
|
||||
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
|
||||
//!
|
||||
//! r2 把空闲的帧订阅掐掉之后,`config_poll_due` 就再也没被调用过 —— 外部 CLI / AI 工具改
|
||||
//! `launcher.json` 后主题**永不生效**(不是「等下次交互」,是「等下一次 dispatch」)。这与
|
||||
//! D4「AI 能读改 ULL 配置」直接冲突。
|
||||
//!
|
||||
//! 所以空闲态换成一个 **1 Hz 的自续心跳**([`crate::app::message::Message::Heartbeat`],
|
||||
//! 由 `Task::perform(sleep(1s))` 驱动,见 `update::heartbeat`):每秒醒一次、推进一拍
|
||||
//! presenter(内部按 33 ms 折算 30 拍,于是 `config_poll_due` 仍是「约 1 秒比对一次 mtime」)。
|
||||
//!
|
||||
//! 为什么不用 `window::frames()` + 1 s 闸门来实现这个低频时钟:帧订阅**本身就是**自持重绘循环
|
||||
//! (iced_winit 处理完任何消息都 `request_redraw()`),闸门只能省掉 `advance()` 里的活,省不掉
|
||||
//! 整窗重绘——实测软件渲染约 **500 帧/秒**。心跳是「每秒一帧」,差三个数量级。
|
||||
//!
|
||||
//! ## 已知代价(都登记,不藏)
|
||||
//!
|
||||
//! 1. **忙碌态仍是自持帧循环**:`Running`/`Busy` 一天不结束,就一天按帧刷(软件渲染实测约
|
||||
//! 970 帧/秒,refreshes 不增长 = 纯像素空转)。功能上需要 tick 来发现游戏退出,但「整段
|
||||
//! 运行期近千帧/秒重绘」是真实开销;要收口得让 `Running` 走更慢的时钟(follow-up)。
|
||||
//! 2. 心跳用 `thread::sleep` 占线程池一个线程 1 秒;1 Hz 下可接受,但不是优雅解法(正解仍是
|
||||
//! `iced::time::every` + tokio/smol,Cargo.toml 在 scope 外,已报 finding)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Subscription};
|
||||
|
||||
use super::components::motion;
|
||||
use super::message::Message;
|
||||
use super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 本次进程的订阅表。
|
||||
pub(super) fn subscription(&self) -> Subscription<Message> {
|
||||
let frames = if self.needs_frames() {
|
||||
window::frames().map(|_| Message::Frame)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
|
||||
// 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。
|
||||
let events = if self.has_screenshot() {
|
||||
Subscription::none()
|
||||
} else {
|
||||
window::events().map(|(_, event)| Message::Window(event))
|
||||
};
|
||||
|
||||
// 键盘(M104):`Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键。
|
||||
//
|
||||
// 为什么订阅整条键盘流而不是自己挑键:`iced::keyboard::listen()` 是唯一的全窗键盘入口,
|
||||
// 挑键与「什么状态下该做什么」的解释权放在 `app/update/agent.rs`(一眼看完),
|
||||
// 订阅表只负责搬运。截图通道不开它——D30 要可复现,键盘事件一个都不该进图。
|
||||
let keys = if self.has_screenshot() {
|
||||
Subscription::none()
|
||||
} else {
|
||||
iced::keyboard::listen()
|
||||
.map(|event| Message::Agent(crate::shell::agent::Message::Key(event)))
|
||||
};
|
||||
|
||||
Subscription::batch([frames, events, keys])
|
||||
}
|
||||
|
||||
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
|
||||
pub(super) fn wants_frames(&self) -> bool {
|
||||
self.has_screenshot()
|
||||
}
|
||||
|
||||
/// 现在要不要**时钟**(见模块文档的表)。
|
||||
pub(super) fn needs_frames(&self) -> bool {
|
||||
self.has_screenshot()
|
||||
// M192:只有动画在跑时才多这一条(帧泵清空轨道后立刻为假 ⇒ 退回 1 Hz 心跳)。
|
||||
|| motion::needs_frames()
|
||||
|| !self.state.toasts.is_empty()
|
||||
|| !self.state.running().is_empty()
|
||||
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
||||
|| self.settings.java.scanning.is_some()
|
||||
|| self
|
||||
.watch_until
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! 帧订阅的**有界**守卫(M192):动画期间订阅、结束即退订、关掉动效一帧都不订阅。
|
||||
//!
|
||||
//! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训)
|
||||
//!
|
||||
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
|
||||
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。
|
||||
//! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表,
|
||||
//! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。
|
||||
//!
|
||||
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion::{Slot, FRAME_MS};
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 走到「真·空闲」:没有观察窗、没有忙碌实例、没有动画(首屏那一条 Boot 已跑完)。
|
||||
///
|
||||
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
|
||||
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
||||
fn idle_app() -> App {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
app
|
||||
}
|
||||
|
||||
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
|
||||
fn run_until_idle() {
|
||||
for _ in 0..1250 {
|
||||
if !motion::needs_frames() {
|
||||
return;
|
||||
}
|
||||
motion::advance_by(FRAME_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// **按运行时顺序**:空闲态一条消息进来(换页)⇒ **同一拍**订阅表就要有帧;跑完立刻退订。
|
||||
///
|
||||
/// 变异证明:把起跑搬回 `view`(删掉 `App::update` 里的 `sync_motion`)⇒ 第一步的
|
||||
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
|
||||
#[test]
|
||||
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
|
||||
let mut app = idle_app();
|
||||
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
|
||||
|
||||
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
|
||||
assert!(
|
||||
app.watch_until.is_none(),
|
||||
"换页不该打开观察窗(这里要证明帧是**动效**要来的,不是派发顺手开的)"
|
||||
);
|
||||
assert!(motion::needs_frames(), "换页必须在同一拍起跑");
|
||||
assert!(
|
||||
app.needs_frames(),
|
||||
"同一拍的订阅表必须能看见轨道(iced 先算订阅表、后建视图 —— 复审 P1-1)"
|
||||
);
|
||||
|
||||
run_until_idle();
|
||||
assert!(!motion::needs_frames(), "动画跑完帧泵必须清空");
|
||||
assert!(
|
||||
!app.needs_frames(),
|
||||
"动画结束后必须退回 1 Hz 心跳(占着帧订阅 = 空闲烧帧)"
|
||||
);
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
|
||||
#[test]
|
||||
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
|
||||
let mut app = idle_app();
|
||||
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
run_until_idle();
|
||||
assert!(!app.needs_frames());
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
assert!(app.needs_frames(), "点进详情必须当拍订阅帧");
|
||||
run_until_idle();
|
||||
assert!(!app.needs_frames());
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
|
||||
#[test]
|
||||
fn pages_that_render_elsewhere_do_not_start_a_track() {
|
||||
let mut app = idle_app();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
|
||||
assert!(
|
||||
!motion::needs_frames(),
|
||||
"账户页没有 pages 树可动,不许白开帧"
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert!(motion::needs_frames(), "从账户页回到某一屏仍然是换页,要跑");
|
||||
run_until_idle();
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **没有窗口就不起跑**:`window::frames()` 在没窗口时一条消息都不会有,起跑只是白占一档;
|
||||
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
|
||||
#[test]
|
||||
fn without_a_window_nothing_animates() {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
assert!(!motion::needs_frames(), "没有窗口 ⇒ 不许起跑");
|
||||
|
||||
// 窗口来了:首屏入场这时才开始(不是被启动期那棵空树吃掉)
|
||||
let _ = app.update(Message::WindowReady(Some(iced::window::Id::unique())));
|
||||
assert!(app.needs_frames(), "窗口到了之后「首屏」才起跑");
|
||||
run_until_idle();
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。
|
||||
///
|
||||
/// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
|
||||
#[test]
|
||||
fn no_scale_requests_no_frames_at_all() {
|
||||
motion::reset();
|
||||
let mut app = headless();
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
motion::configure(MotionScale::NONE, None);
|
||||
|
||||
// 三种状态变化:换页 / 换详情 / 再换回来
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
assert!(!app.needs_frames(), "关掉动效后换页一帧都不该订阅");
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
assert!(!app.needs_frames());
|
||||
run_until_idle();
|
||||
|
||||
assert!(!motion::needs_frames());
|
||||
assert_eq!(motion::samples(), 0, "NONE 档的帧采样次数必须是 0");
|
||||
motion::reset();
|
||||
}
|
||||
|
||||
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
|
||||
///
|
||||
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
|
||||
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
|
||||
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
|
||||
#[test]
|
||||
fn there_is_exactly_one_frame_subscription() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
collect_rust_files(&src, &mut files);
|
||||
assert!(
|
||||
files.len() >= 40,
|
||||
"只扫到 {} 个文件:扫描逻辑坏了",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).expect("读源文件");
|
||||
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
|
||||
let code = product
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("//"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(
|
||||
"
|
||||
",
|
||||
);
|
||||
let count = code.matches("window::frames()").count();
|
||||
if count > 0 {
|
||||
let name = path
|
||||
.strip_prefix(&src)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
hits.push((name, count));
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort();
|
||||
let expected = [
|
||||
("app/subscription.rs".to_string(), 1_usize),
|
||||
("pages/harness.rs".to_string(), 1_usize),
|
||||
];
|
||||
assert_eq!(
|
||||
hits, expected,
|
||||
"`window::frames()` 只许出现在 `app/subscription.rs`(产品接线)\
|
||||
与 `pages/harness.rs`(独立 example 的自有 app),且各一处"
|
||||
);
|
||||
}
|
||||
|
||||
/// 递归收集 `dir/**/*.rs`。
|
||||
fn collect_rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).expect("读目录") {
|
||||
let path = entry.expect("目录项").path();
|
||||
if path.is_dir() {
|
||||
collect_rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
|
||||
#[test]
|
||||
fn slots_are_the_three_documented_ones() {
|
||||
assert_eq!(Slot::ALL.len(), 3);
|
||||
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。
|
||||
//!
|
||||
//! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的
|
||||
//! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」
|
||||
//! 这件事在这里就能判死,不必等图形环境。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
#[cfg(test)]
|
||||
mod appearance;
|
||||
#[cfg(test)]
|
||||
mod instances_save_loop;
|
||||
#[cfg(test)]
|
||||
mod presenter;
|
||||
|
||||
use super::message::Message;
|
||||
use super::state::App;
|
||||
use super::{APP_TITLE, WINDOW_SIZE};
|
||||
use crate::cli::{Options, Screen};
|
||||
use crate::pages;
|
||||
use crate::shell::agent::RailState;
|
||||
use crate::shell::NavItem;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥)。
|
||||
fn headless(options: Options, flavor: SchemeFlavor) -> App {
|
||||
App::with_presenter(
|
||||
options,
|
||||
FAMILIES,
|
||||
flavor,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn screenshot_options() -> Options {
|
||||
Options {
|
||||
screenshot: Some(std::path::PathBuf::from("x.png")),
|
||||
..Options::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.state.navigate(Route::Logs);
|
||||
app.refresh_view();
|
||||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||||
|
||||
app.set_flavor(SchemeFlavor::Dark);
|
||||
|
||||
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
|
||||
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
|
||||
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
|
||||
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_is_the_acceptance_size() {
|
||||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||
assert_eq!(APP_TITLE, "Unlucky Launcher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_mode_never_waits_for_the_system_theme() {
|
||||
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
|
||||
let options = screenshot_options();
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||||
|
||||
let app = headless(options, SchemeFlavor::Light);
|
||||
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_screen_builds_a_tree() {
|
||||
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
|
||||
let options = Options {
|
||||
screen: Screen::Gallery,
|
||||
..Options::default()
|
||||
};
|
||||
let app = headless(options, SchemeFlavor::Light);
|
||||
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_are_only_requested_in_screenshot_mode() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘");
|
||||
|
||||
app.options.screenshot = Some(std::path::PathBuf::from("x.png"));
|
||||
assert!(
|
||||
app.wants_frames(),
|
||||
"截图模式必须订阅帧事件才能在第 3 帧出图"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- P2-1 按需节拍
|
||||
|
||||
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
|
||||
///
|
||||
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
|
||||
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
|
||||
///
|
||||
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
|
||||
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
|
||||
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
|
||||
///
|
||||
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
|
||||
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
|
||||
#[test]
|
||||
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
|
||||
let app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
|
||||
|
||||
let mut idle = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = idle.update(Message::Boot);
|
||||
assert!(
|
||||
idle.needs_frames(),
|
||||
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
|
||||
);
|
||||
|
||||
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
|
||||
idle.watch_until = None;
|
||||
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
|
||||
|
||||
// 派发之后观察窗重新打开
|
||||
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
let _ = idle.update(Message::Page(pages::Message::Launch(
|
||||
"survival".to_string(),
|
||||
)));
|
||||
assert!(idle.needs_frames(), "派发后观察窗内要有节拍");
|
||||
|
||||
// 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗)
|
||||
idle.watch_until = None;
|
||||
assert!(
|
||||
idle.needs_frames(),
|
||||
"有 Busy 实例时即便观察窗过期也要继续 tick"
|
||||
);
|
||||
}
|
||||
|
||||
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
|
||||
///
|
||||
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
|
||||
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
|
||||
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
|
||||
#[test]
|
||||
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
|
||||
use unlucky_presenter::theme::config_poll_due;
|
||||
|
||||
assert_eq!(
|
||||
super::state::IDLE_TICK,
|
||||
std::time::Duration::from_secs(1),
|
||||
"空闲心跳周期必须是 1 s"
|
||||
);
|
||||
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
|
||||
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
|
||||
let before = app.ticks;
|
||||
app.advance(super::state::IDLE_TICK);
|
||||
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
|
||||
assert!(
|
||||
config_poll_due(app.ticks),
|
||||
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
|
||||
app.ticks
|
||||
);
|
||||
}
|
||||
|
||||
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
|
||||
///
|
||||
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
|
||||
/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。
|
||||
/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。
|
||||
#[test]
|
||||
fn idle_ticks_do_not_rebuild_the_view_model() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
let before = app.refreshes;
|
||||
for _ in 0..100 {
|
||||
app.advance(super::state::TICK);
|
||||
}
|
||||
assert_eq!(
|
||||
app.refreshes,
|
||||
before,
|
||||
"空闲 100 拍:视图模型一次都不该重建(实际 +{})",
|
||||
app.refreshes - before
|
||||
);
|
||||
|
||||
// 有事件进来时仍然要重建(否则页面不刷新)
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
|
||||
assert!(app.refreshes > before, "导航之后必须重建一次");
|
||||
}
|
||||
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
|
||||
///
|
||||
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
|
||||
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
|
||||
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
|
||||
#[test]
|
||||
fn resizing_the_window_rebuilds_the_view_model() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!(
|
||||
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
|
||||
"1180 − 48(折叠条)= 1132,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(700.0, 600.0),
|
||||
)));
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 − 48(折叠条)= 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
|
||||
app.agent.expand();
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!(
|
||||
(app.view.window_width - 852.0).abs() < f32::EPSILON,
|
||||
"1180 − 320 − 8 = 852,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180。
|
||||
// **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式,
|
||||
// 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
|
||||
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
// 页面照旧能构图(窄窗退化到单列,不 panic)
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。
|
||||
///
|
||||
/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是
|
||||
/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。
|
||||
///
|
||||
/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`);
|
||||
/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航,
|
||||
/// 会话收回右栗(档位不再停在 Detached)。
|
||||
/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。
|
||||
#[test]
|
||||
fn entering_the_agent_route_detaches_the_rail() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||||
// M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读");
|
||||
|
||||
// ① 外壳导航第五项(真实入口)
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
assert_eq!(app.state.route(), &Route::Agent);
|
||||
assert_eq!(
|
||||
app.agent.rail,
|
||||
RailState::Detached,
|
||||
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
|
||||
);
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读");
|
||||
|
||||
// ③ Esc 真的离开这一页
|
||||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
|
||||
assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开");
|
||||
assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档");
|
||||
|
||||
// ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached)
|
||||
let _ = app.update(Message::Select(NavItem::Ai));
|
||||
assert_eq!(app.agent.rail, RailState::Detached);
|
||||
let _ = app.update(Message::Select(NavItem::Instances));
|
||||
assert_eq!(app.state.route(), &Route::Instances);
|
||||
assert_eq!(
|
||||
app.agent.rail,
|
||||
RailState::Expanded,
|
||||
"离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。
|
||||
#[test]
|
||||
fn the_page_navigation_entry_also_detaches() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
// 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生
|
||||
let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand));
|
||||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
|
||||
assert_eq!(app.state.route(), &Route::Agent);
|
||||
assert_eq!(app.agent.rail, RailState::Detached);
|
||||
assert_eq!(app.agent_host.rail().unread, 0);
|
||||
|
||||
// 页面层回退(不是 Dock 消息)也要把档位收回来
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert_eq!(app.agent.rail, RailState::Expanded);
|
||||
}
|
||||
|
||||
/// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。
|
||||
#[test]
|
||||
fn screenshot_mode_ignores_the_settings_file_theme() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
app.apply_config_theme();
|
||||
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
|
||||
}
|
||||
|
||||
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
|
||||
///
|
||||
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
|
||||
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
|
||||
/// 出来,一并修)。
|
||||
///
|
||||
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
|
||||
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
|
||||
#[test]
|
||||
fn the_system_theme_never_overrides_an_explicit_one() {
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
|
||||
// ① auto(内存桥默认):系统主题说了算
|
||||
let mut auto = headless(Options::default(), SchemeFlavor::Light);
|
||||
assert_eq!(auto.theme_mode, ThemeMode::Auto);
|
||||
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
|
||||
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
|
||||
|
||||
// ② 配置文件显式 dark:系统给浅色也不许改
|
||||
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
|
||||
pinned.theme_mode = ThemeMode::Dark;
|
||||
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||||
assert_eq!(
|
||||
pinned.ui().name(),
|
||||
"ull-m3-dark",
|
||||
"配置文件说 dark,系统说 light:不许改"
|
||||
);
|
||||
|
||||
// ③ 命令行 `--theme` 优先级最高
|
||||
let mut explicit = headless(
|
||||
Options {
|
||||
theme: Some(SchemeFlavor::Dark),
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Dark,
|
||||
);
|
||||
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||||
assert_eq!(
|
||||
explicit.ui().name(),
|
||||
"ull-m3-dark",
|
||||
"--theme 优先级最高,系统主题不许盖"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
|
||||
//!
|
||||
//! 每条都按本 mission 的验收纪律写「抓得住 / 抓不住」。真机才有的东西(`WS_EX_LAYERED` 是否补上、
|
||||
//! `LWA_ALPHA` 的观感)不在这里假装——那是 `[appearance]` 日志与报告里「没验的部分」的事。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::prefs::{BgFill, PaletteSource};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::cli::{Backend, Options};
|
||||
use crate::pages::appearance::{self, Change, State};
|
||||
use crate::pages::{self, Screen};
|
||||
|
||||
use super::{headless, FAMILIES};
|
||||
|
||||
use super::super::state::App;
|
||||
|
||||
/// 一个把配置写在临时文件里的 App(测「读回 / 写回」必须真的过文件)。
|
||||
fn with_config(json: &str, backend: Backend, label: &str) -> (App, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-appear-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
std::fs::write(&path, json).expect("写配置文件");
|
||||
|
||||
let app = App::with_presenter(
|
||||
Options {
|
||||
backend,
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
fn cleanup(dir: &PathBuf) {
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。
|
||||
/// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。
|
||||
#[test]
|
||||
fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() {
|
||||
let (app, dir) = with_config(
|
||||
r#"{"appearance":{"window_opacity":0.8,"palette_source":"preset"}}"#,
|
||||
Backend::Cpu,
|
||||
"read",
|
||||
);
|
||||
|
||||
assert_eq!(app.appearance.window_opacity, 0.8);
|
||||
assert_eq!(
|
||||
app.appearance.window_alpha_byte(),
|
||||
Some(205),
|
||||
"f32 0.8 → f64 略大于 0.8 → ceil 205(不向下取整)"
|
||||
);
|
||||
// CPU 档:窗口 α 允许,面板 α 强制 1.0。
|
||||
assert_eq!(app.appearance.effective_panel_alpha(), 1.0);
|
||||
assert!(app.appearance.panel_is_forced());
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// 抓得住:①面板 α 滑到 0.8 → 状态是 0.8、生效值 GPU 档也是 0.8;
|
||||
/// ②CPU 档下滑到 0.5 → 生效值仍是 1.0,而**请求值**仍是 0.5(界面上的「requested → forced」靠这条)。
|
||||
/// 抓不住:这层半透明在屏幕上到底多透(RDP 上不可信,见报告)。
|
||||
#[test]
|
||||
fn panel_alpha_is_independent_of_the_window_alpha_and_forced_on_cpu() {
|
||||
let mut gpu = headless(Options::default(), SchemeFlavor::Light);
|
||||
gpu.appearance.apply(Change::PanelAlpha(0.8));
|
||||
assert_eq!(gpu.appearance.panel_alpha, 0.8);
|
||||
assert_eq!(gpu.appearance.effective_panel_alpha(), 0.8);
|
||||
assert_eq!(gpu.appearance.window_opacity, 1.0, "面板 α 不许带动整窗 α");
|
||||
|
||||
let mut cpu = headless(
|
||||
Options {
|
||||
backend: Backend::Cpu,
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert!(
|
||||
!cpu.appearance.apply(Change::PanelAlpha(0.5)),
|
||||
"只有整窗 α 需要立刻下发(面板 α 只重绘)"
|
||||
);
|
||||
assert_eq!(cpu.appearance.panel_alpha, 0.5, "请求值保留");
|
||||
assert_eq!(
|
||||
cpu.appearance.effective_panel_alpha(),
|
||||
1.0,
|
||||
"CPU 档强制 1.0"
|
||||
);
|
||||
assert_eq!(cpu.appearance.window_alpha_byte(), None, "整窗 α 仍是 1.0");
|
||||
}
|
||||
|
||||
/// 抓得住:一次滑块改动 → 落盘 → **新开一个 bridge** 能读回(= 重启后还在)。
|
||||
/// 抓不住:真 host 是否也写了同一份文件(`AppCommand::SetSetting` 走的是同一份 schema)。
|
||||
#[test]
|
||||
fn a_panel_alpha_change_is_written_back_to_the_config_file() {
|
||||
let (mut app, dir) = with_config("{}", Backend::Gpu, "write");
|
||||
app.on_appearance(Change::PanelAlpha(0.8));
|
||||
app.on_appearance(Change::BgFill(BgFill::Tile));
|
||||
app.on_appearance(Change::BgDim(0.5));
|
||||
|
||||
let path = dir.join("config/launcher.json");
|
||||
let reopened = SettingsBridge::with_path(&path).load().0;
|
||||
assert_eq!(reopened.panel_alpha, 0.8);
|
||||
assert_eq!(reopened.bg_fill, BgFill::Tile);
|
||||
assert!((reopened.bg_dim - 0.5).abs() < 1e-6);
|
||||
assert_eq!(
|
||||
app.appearance_prefs.panel_alpha, 0.8,
|
||||
"内存里的「已落盘快照」也要跟上,否则下一次写回会拿旧基底覆盖"
|
||||
);
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// 抓得住:路径读不出来时 ①不 panic ②`background_active()` 为假(静默回落)
|
||||
/// ③诊断里有一句人话(不是空串)④配色回落到色卡档(不换 scheme)。
|
||||
/// 抓不住:这张图在别的机器上路径存在时会怎样(那是成功路径,另一条测试管)。
|
||||
#[test]
|
||||
fn a_broken_background_path_falls_back_silently_but_says_why() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.appearance
|
||||
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
|
||||
|
||||
assert!(!app.appearance.background_active());
|
||||
assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图");
|
||||
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
|
||||
assert!(
|
||||
app.appearance.diagnostics()[0].contains("could not be read"),
|
||||
"诊断要是人话:{:?}",
|
||||
app.appearance.diagnostics()
|
||||
);
|
||||
assert_eq!(app.appearance.palette_level(), "preset", "回落色卡");
|
||||
assert!(
|
||||
app.appearance.derived_scheme().is_none(),
|
||||
"色卡档不换 scheme"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:真 PNG → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。
|
||||
/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的
|
||||
/// 「覆盖 ≥ 视口」断言守)。
|
||||
#[test]
|
||||
fn a_decodable_png_makes_the_background_and_the_palette_active() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||
let path = dir.join("bg.png");
|
||||
let image = image::RgbImage::from_fn(64, 32, |x, _y| {
|
||||
if x < 32 {
|
||||
image::Rgb([200, 30, 30])
|
||||
} else {
|
||||
image::Rgb([20, 60, 200])
|
||||
}
|
||||
});
|
||||
image.save(&path).expect("写 png");
|
||||
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.appearance
|
||||
.apply(Change::PaletteSource(PaletteSource::BackgroundImage));
|
||||
app.appearance
|
||||
.apply(Change::BgImage(path.to_string_lossy().to_string()));
|
||||
app.rebuild_ui();
|
||||
|
||||
assert!(app.appearance.background_active());
|
||||
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
|
||||
assert!(
|
||||
!app.appearance.bg_path.trim().is_empty(),
|
||||
"渲染器按路径解码(见 picture::image_handle)"
|
||||
);
|
||||
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
|
||||
assert_eq!(app.appearance.palette_level(), "background-image");
|
||||
assert!(
|
||||
app.appearance.derived_scheme().is_some(),
|
||||
"取色成功后 Ui 要换成解析出的方案"
|
||||
);
|
||||
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
|
||||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||||
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
|
||||
assert_ne!(
|
||||
app.ui().color(unlucky_design::color::Role::Primary),
|
||||
baseline.color(unlucky_design::color::Role::Primary)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 抓得住:设置页在有外观状态时能真的构图(真控件树,含文本框/滑块/互斥按钮),并且
|
||||
/// 「外观」分组的下标与 `appearance::APPEARANCE_GROUP` 一致(点左列能切过去)。
|
||||
/// 抓不住:这些控件点下去之后的交互(`Message` 的落点由 `on_appearance` 的其它测试管)。
|
||||
#[test]
|
||||
fn the_settings_page_renders_the_real_appearance_group() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.state
|
||||
.navigate(unlucky_presenter::router::Route::Settings);
|
||||
app.refresh_view();
|
||||
assert_eq!(app.view.screen, Screen::Settings);
|
||||
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
|
||||
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
drop(_tree);
|
||||
|
||||
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
|
||||
app.appearance.apply(Change::SelectGroup(0));
|
||||
assert_eq!(app.appearance.selected_group, 0);
|
||||
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance, &app.launch_form);
|
||||
}
|
||||
|
||||
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
|
||||
///
|
||||
/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas,
|
||||
/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。
|
||||
/// 抓不住:真实合成后的观感(那由截图看)。
|
||||
#[test]
|
||||
fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() {
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.appearance.apply(Change::PanelAlpha(0.55));
|
||||
app.rebuild_ui();
|
||||
|
||||
let ui = app.ui();
|
||||
assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui");
|
||||
assert_eq!(
|
||||
ui.layer(MaterialLayer::Canvas).a,
|
||||
1.0,
|
||||
"窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量"
|
||||
);
|
||||
for layer in [MaterialLayer::Card, MaterialLayer::Overlay] {
|
||||
assert!(
|
||||
(ui.layer(layer).a - 0.55).abs() < 1e-6,
|
||||
"{layer:?} 必须吃面板 α(实际 {})",
|
||||
ui.layer(layer).a
|
||||
);
|
||||
}
|
||||
|
||||
// CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。
|
||||
let mut cpu = headless(
|
||||
Options {
|
||||
backend: Backend::Cpu,
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
cpu.appearance.apply(Change::PanelAlpha(0.55));
|
||||
cpu.rebuild_ui();
|
||||
assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0");
|
||||
}
|
||||
|
||||
/// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调
|
||||
/// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。
|
||||
/// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。
|
||||
/// 抓不住:拖动的手感与真实观感(RDP 上不可信)。
|
||||
#[test]
|
||||
fn a_slider_sweep_moves_the_alpha_and_the_byte_monotonically() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let mut bytes = Vec::new();
|
||||
for step in 0..=10 {
|
||||
let fraction = step as f32 / 10.0;
|
||||
let alpha = crate::pages::appearance::alpha::window_alpha_from_fraction(fraction);
|
||||
app.appearance.apply(Change::WindowOpacity(alpha));
|
||||
let byte = app.appearance.window_alpha_byte();
|
||||
bytes.push(byte);
|
||||
// 每一档都要能打出一行(数值而不是观感)。
|
||||
assert!(app.appearance.log_line("sweep").contains("window_alpha="));
|
||||
}
|
||||
|
||||
assert_eq!(bytes[0], Some(21), "0% 一档 = 下限 0.08 → 21");
|
||||
assert_eq!(bytes[10], None, "100% 一档 = 不透明 → 不下发");
|
||||
let sent: Vec<u8> = bytes.iter().flatten().copied().collect();
|
||||
assert!(
|
||||
sent.windows(2).all(|pair| pair[0] <= pair[1]),
|
||||
"字节必须单调不减:{sent:?}"
|
||||
);
|
||||
assert_eq!(sent.len(), 10, "除最后一档(不透明)之外每档都有字节");
|
||||
assert!(
|
||||
sent.iter().all(|byte| *byte >= 21),
|
||||
"任何一档都不许低于下限字节:{sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 抓得住:日志行里有请求值、生效值与下发字节(RDP 上唯一可信的证据形态)。
|
||||
/// 抓不住:这行日志是否真的被写进了 stderr(本测试只钉格式,真机日志由跑起来的二进制举证)。
|
||||
#[test]
|
||||
fn the_log_line_carries_requests_effective_values_and_the_byte() {
|
||||
let mut cpu = headless(
|
||||
Options {
|
||||
backend: Backend::Cpu,
|
||||
..Options::default()
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
cpu.appearance.apply(Change::WindowOpacity(0.5));
|
||||
cpu.appearance.apply(Change::PanelAlpha(0.4));
|
||||
|
||||
let line = cpu.appearance.log_line("change");
|
||||
assert!(line.starts_with("[appearance] change "), "{line}");
|
||||
assert!(line.contains("window_alpha=0.500"), "{line}");
|
||||
assert!(line.contains("panel_alpha=1.000"), "CPU 档生效值:{line}");
|
||||
assert!(line.contains("(requested 0.400)"), "请求值也要在:{line}");
|
||||
assert!(
|
||||
line.contains("byte=128"),
|
||||
"0.5×255=127.5 → 向上取整 128:{line}"
|
||||
);
|
||||
|
||||
// 不透明时不给字节(而不是给 255)。
|
||||
let opaque = headless(Options::default(), SchemeFlavor::Light);
|
||||
assert!(opaque.appearance.log_line("boot").contains("byte=none"));
|
||||
}
|
||||
|
||||
/// 抓得住:`State::new` 与 `apply_env` 之外,`State` 的构造不会偷偷读环境(测试之间不串味)。
|
||||
/// 抓不住:`ULL_APPEARANCE_*` 在真机上的解析(`apply_env` 由截图矩阵覆盖,见报告)。
|
||||
#[test]
|
||||
fn a_fresh_state_is_the_factory_appearance() {
|
||||
let state = State::new(
|
||||
&unlucky_presenter::prefs::AppearancePrefs::factory(),
|
||||
&unlucky_presenter::prefs::AppearanceBase::factory(),
|
||||
Backend::Gpu,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(state.window_opacity, 1.0);
|
||||
assert_eq!(state.effective_panel_alpha(), 1.0);
|
||||
assert!(!state.background_active());
|
||||
assert!(state.diagnostics().is_empty());
|
||||
assert!(state.image().is_none());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 「启动参数保存失败」的**整环**回归(M157 r4 复审 P3-a)。
|
||||
//!
|
||||
//! 仓里原来只有这条环的两半:`LaunchForm::sync` 的单测(本 crate 的 `pages/instances/state.rs`)
|
||||
//! 与「Save 只派发不清脏」(`app/update/instances.rs`)。两半各自绿,但**接线**(消息 →
|
||||
//! `dispatch` → 后端回事件 → `pump` → `refresh_view` → `sync_launch_form`)没有任何活着的断言。
|
||||
//! 把 `dirty = false` 提前到派发前,只有这条整环测试会红(单测两半都不会)。
|
||||
//!
|
||||
//! 造的后端与真 host 的失败路径同形:收到 `SetInstanceLaunchArgs` 只回一条 Toast(错误的用户
|
||||
//! 可见反馈),**不回** `InstancesChanged`——盘上没改,快照还是旧的。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchArgsView,
|
||||
LoaderInfo, LoaderKind,
|
||||
};
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::toast::{Toast, ToastKind};
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::{App, TICK};
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 失败路径的后端:命令进台账;`SetInstanceLaunchArgs` 只回一条错误 Toast。
|
||||
#[derive(Debug, Default)]
|
||||
struct FailingSaveBackend {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for FailingSaveBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.queued)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command.clone());
|
||||
if matches!(command, AppCommand::SetInstanceLaunchArgs { .. }) {
|
||||
// 真 host 在 `dispatch_inner` 的 Err 分支只发 toast(不发 InstancesChanged)。
|
||||
self.queued.push(AppEvent::Toast(Toast {
|
||||
id: 0,
|
||||
kind: ToastKind::Error,
|
||||
text: "内存超出范围".to_string(),
|
||||
remaining_ms: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 盘上的实例快照:内存 4096、其余留空(= 保存失败时 pump 会带回来的那份)。
|
||||
fn snapshot() -> Vec<InstanceSummary> {
|
||||
vec![InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView {
|
||||
instance_id: "survival".to_string(),
|
||||
memory_mb: Some(4096),
|
||||
memory_effective_mb: 4096,
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
fn app_with_snapshot() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let dispatched = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = FailingSaveBackend {
|
||||
dispatched: Rc::clone(&dispatched),
|
||||
queued: vec![AppEvent::Core(CoreEvent::InstancesChanged(snapshot()))],
|
||||
};
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, dispatched)
|
||||
}
|
||||
|
||||
/// **保存失败不许吃掉草稿**(整环):输入 `4g` → 点保存 → 后端回错误事件 → pump 重建视图。
|
||||
///
|
||||
/// 抓得住:①`Save` 在派发前清脏(草稿被旧快照覆盖成 4096);②`LaunchForm::sync` 去掉
|
||||
/// `matches` 判据(同样覆盖);③`pump` 没把事件喂进 `apply`/`refresh_view`(表单不会被播种,
|
||||
/// 下面的前置断言会红)。
|
||||
/// 抓不住:真 host 的落盘失败(那是 `host::launch_args` 的单测射程)。
|
||||
#[test]
|
||||
fn failed_save_keeps_the_draft_through_the_whole_loop() {
|
||||
let (mut app, dispatched) = app_with_snapshot();
|
||||
|
||||
// 首屏快照到位 → 自动选中 → 表单按盘上的值播种。
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
app.advance(TICK);
|
||||
assert_eq!(app.launch_form.memory, "4096", "首帧要按快照播种");
|
||||
assert!(!app.launch_form.dirty);
|
||||
|
||||
// 用户把内存改成非法值(host 会拒),然后点保存。
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Memory("4g".to_string()),
|
||||
)));
|
||||
assert_eq!(app.launch_form.memory, "4g");
|
||||
let _ = app.update(Message::Page(pages::Message::Instances(
|
||||
pages::InstanceMessage::Save,
|
||||
)));
|
||||
assert!(
|
||||
dispatched
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|command| matches!(command, AppCommand::SetInstanceLaunchArgs { .. })),
|
||||
"保存要把命令派发出去"
|
||||
);
|
||||
|
||||
// 失败回程:一条错误 Toast → pump → refresh_view → sync_launch_form(旧快照)。
|
||||
app.advance(TICK);
|
||||
|
||||
assert_eq!(
|
||||
app.launch_form.memory, "4g",
|
||||
"保存失败后,pump 用旧快照把用户刚敲的内容冲掉了"
|
||||
);
|
||||
assert!(app.launch_form.dirty, "草稿还在,脏标记也不能掉");
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! presenter 闭环与节拍的断言(M90 r2 从 `app/tests.rs` 外置:那边 416 行会被 file-size 判红)。
|
||||
//!
|
||||
//! 这里的每条都依赖一个可注入的 `Backend`:
|
||||
//! - `MockBackend`(presenter 自带)验「命令 → 事件 → 状态」的闭环;
|
||||
//! - `RecorderBackend`(本文件,只记账不回事件)验「UI 侧不做乐观回显」——用 MockBackend 验这条
|
||||
//! 会得到**假绿**,因为它是同步发事件的,本地回显与事件回流终态相同(P3-2 的教训)。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
use super::*;
|
||||
|
||||
// --------------------------------------------------------------- presenter 闭环
|
||||
|
||||
/// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。
|
||||
#[test]
|
||||
fn boot_drains_the_first_snapshot_before_the_first_frame() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
assert!(app.view.instances.rows.is_empty(), "还没 Boot");
|
||||
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
assert_eq!(
|
||||
app.view.instances.rows.len(),
|
||||
4,
|
||||
"mock 的四个实例已进视图模型"
|
||||
);
|
||||
assert_eq!(app.view.screen, pages::Screen::Instances);
|
||||
}
|
||||
|
||||
/// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。
|
||||
#[test]
|
||||
fn the_first_snapshot_selects_the_first_instance_once() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
assert_eq!(
|
||||
app.state.route(),
|
||||
&Route::Detail("survival".to_string()),
|
||||
"首屏快照到位后自动选中第一行"
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert_eq!(app.state.route(), &Route::Instances);
|
||||
|
||||
// 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页
|
||||
app.advance(super::super::state::TICK);
|
||||
assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次");
|
||||
}
|
||||
|
||||
/// 只记账、**不回任何事件**的后端(P3-2)。
|
||||
///
|
||||
/// 为什么需要它:`MockBackend::dispatch` 是**同步**发事件的,于是「UI 本地回显」与「事件回流」
|
||||
/// 在 dispatch 之后终态相同——拿 MockBackend 测「不乐观回显」是假绿(r1 就是栽在这条上)。
|
||||
/// 这个后端把两件事分开:命令只进 `dispatched`,`drain_events` 永远空 ⇒ 状态**不可能**变;
|
||||
/// 一旦有人在 `update` 里偷偷改本地状态,下面那条断言立刻红(实测见报告「P3-2 取证」)。
|
||||
#[derive(Debug, Default)]
|
||||
struct RecorderBackend {
|
||||
dispatched: Rc<RefCell<Vec<AppCommand>>>,
|
||||
seeded: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for RecorderBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.seeded)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.dispatched.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 recorder 后端造一个 App(首屏快照靠 `seeded` 一次性喂进去)。
|
||||
fn recording(options: Options, flavor: SchemeFlavor) -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let recorded = Rc::new(RefCell::new(Vec::new()));
|
||||
// 注意:`InstancesChanged` 是**整体替换**,所以四条必须合成一条事件;
|
||||
// 分开发的话最后只会剩一个实例(r1 在这条上踩过)。
|
||||
let backend = RecorderBackend {
|
||||
dispatched: Rc::clone(&recorded),
|
||||
seeded: vec![AppEvent::Core(
|
||||
CoreEvent::InstancesChanged(mock_instances()),
|
||||
)],
|
||||
};
|
||||
let app = App::with_presenter(
|
||||
options,
|
||||
FAMILIES,
|
||||
flavor,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, recorded)
|
||||
}
|
||||
|
||||
/// **P3-2:不做乐观回显**——命令必须真的出 UI 层,而状态在后端回事件之前**一个字节都不许变**。
|
||||
///
|
||||
/// 抓得住:`update` 里出现「本地先改状态」的写法(recorder 永远不回事件,本地一改这里就红)。
|
||||
/// 抓不住:「命令有没有真的到达 host」——那由 `MockBackend` 的闭环测试与真机验证负责。
|
||||
#[test]
|
||||
fn launching_only_dispatches_and_never_echoes_locally() {
|
||||
let mut options = screenshot_options();
|
||||
options.screenshot = None; // recorder 不依赖截图通道
|
||||
let (mut app, recorded) = recording(options, SchemeFlavor::Light);
|
||||
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
|
||||
// Boot 自己发过一条 RefreshAll,先把账本清掉,下面只看「点 Play」这一个动作
|
||||
recorded.borrow_mut().clear();
|
||||
|
||||
let before = app.view.instances.detail.clone().expect("有选中项");
|
||||
assert!(before.can_launch && !before.can_stop && before.status.is_none());
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Launch(
|
||||
"survival".to_string(),
|
||||
)));
|
||||
|
||||
// ① 命令真的出 UI 层了
|
||||
assert_eq!(
|
||||
*recorded.borrow(),
|
||||
vec![AppCommand::LaunchInstance("survival".to_string())],
|
||||
"Launch 必须翻成一条 AppCommand::LaunchInstance 并交给 Backend::dispatch"
|
||||
);
|
||||
|
||||
// ② 后端一条事件都没回 ⇒ 视图模型必须**原样不动**(任何本地回显都会让这条红)
|
||||
let after = app.view.instances.detail.clone().expect("有选中项");
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"没有事件回流时页面不许自己变(乐观回显);现在的差异是 {after:?}"
|
||||
);
|
||||
assert!(!after.can_stop, "不许因为点了 Play 就本地切成「可停止」");
|
||||
assert!(after.status.is_none(), "不许本地造一条进度文案");
|
||||
}
|
||||
|
||||
/// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(真后端的闭环)。
|
||||
#[test]
|
||||
fn launching_goes_through_the_backend_and_comes_back_as_events() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
|
||||
let detail = app.view.instances.detail.as_ref().expect("有选中项");
|
||||
assert!(
|
||||
detail.can_launch && !detail.can_stop,
|
||||
"空闲:可启动不可停止"
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Launch(
|
||||
"survival".to_string(),
|
||||
)));
|
||||
|
||||
let detail = app.view.instances.detail.as_ref().expect("有选中项");
|
||||
assert!(
|
||||
!detail.can_launch && detail.can_stop,
|
||||
"派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转"
|
||||
);
|
||||
// 阶段名跟界面语言走(M120):断言对着**当前生效语言**的目录算,别在中文机器上钉死英文
|
||||
let stage = app.lang().catalog().text(Key::InstancesStageDownload);
|
||||
assert_eq!(
|
||||
detail.status.as_deref(),
|
||||
Some(format!("{stage} · 0%").as_str()),
|
||||
"状态行来自事件里的进度,不是本地假造"
|
||||
);
|
||||
}
|
||||
|
||||
/// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。
|
||||
#[test]
|
||||
fn ticks_advance_the_backend_script() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
let _ = app.update(Message::Page(pages::Message::Launch(
|
||||
"survival".to_string(),
|
||||
)));
|
||||
|
||||
// 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify
|
||||
for _ in 0..50 {
|
||||
app.advance(super::super::state::TICK);
|
||||
}
|
||||
|
||||
let detail = app.view.instances.detail.as_ref().expect("有选中项");
|
||||
let status = detail.status.as_deref().unwrap_or_default();
|
||||
let catalog = app.lang().catalog();
|
||||
let (verify, launch) = (
|
||||
catalog.text(Key::InstancesStageVerify),
|
||||
catalog.text(Key::InstancesStageLaunch),
|
||||
);
|
||||
assert!(
|
||||
status.starts_with(verify) || status.starts_with(launch),
|
||||
"50 拍之后应已过下载阶段,实际 {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。
|
||||
#[test]
|
||||
fn stopping_returns_to_idle_through_an_event() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
let _ = app.update(Message::Page(pages::Message::Launch(
|
||||
"survival".to_string(),
|
||||
)));
|
||||
let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string())));
|
||||
|
||||
let detail = app.view.instances.detail.as_ref().expect("有选中项");
|
||||
assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲");
|
||||
assert!(detail.status.is_none(), "空闲没有状态行");
|
||||
}
|
||||
|
||||
/// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。
|
||||
#[test]
|
||||
fn navigation_follows_the_presenter_router() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail(
|
||||
"survival".to_string(),
|
||||
))));
|
||||
assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页");
|
||||
assert!(app.state.router.can_go_back());
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
|
||||
assert_eq!(app.view.screen, pages::Screen::Settings);
|
||||
assert_eq!(
|
||||
app.view.active,
|
||||
Some(pages::Screen::Settings.nav()),
|
||||
"导航轨高亮跟着路由走"
|
||||
);
|
||||
assert!(
|
||||
!app.state.router.can_go_back(),
|
||||
"一级页互相替换,不进栈(presenter::router 的语义)"
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
assert_eq!(app.view.screen, pages::Screen::Instances);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。
|
||||
//!
|
||||
//! ## 数据流的三个入口(presenter crate 文档 §2)
|
||||
//!
|
||||
//! ```text
|
||||
//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型
|
||||
//! ▲ ▲
|
||||
//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz
|
||||
//! ```
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变
|
||||
//! (否则事件与本地状态打架时,磁贴与进度条会各说一套);
|
||||
//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由);
|
||||
//! 3. **只在状态变了才重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点,
|
||||
//! 由 `view_dirty` 门控(M90 r2/P2-1 起;此前是每拍无条件重建)。
|
||||
//!
|
||||
//! ## 两个时钟(M90 r3 / P2-2)
|
||||
//!
|
||||
//! | 场景 | 时钟 | 周期 | 谁触发 |
|
||||
//! |---|---|---|---|
|
||||
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
||||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
|
||||
//!
|
||||
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
||||
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
|
||||
//! 下一拍就退回心跳(见 `components::motion`)。
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::app::diag::diag;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use crate::pages;
|
||||
use crate::renderer::Kind;
|
||||
|
||||
use super::message::{route_of, Message};
|
||||
use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
|
||||
mod account;
|
||||
mod agent;
|
||||
mod appearance;
|
||||
pub(super) mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod content_pack;
|
||||
mod dialog;
|
||||
mod dialog_fixture;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod motion_sync;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
mod update_action;
|
||||
|
||||
/// 这一条键盘事件是不是 `Esc` 按下。
|
||||
fn is_escape(event: &iced::keyboard::Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
///
|
||||
/// **分两段**:先派发([`App::route_message`]),再同步一次动效的内容标识。第二段必须在
|
||||
/// `update` 里而不是 `view` 里:iced 求值订阅表**早于**建视图(`app/update/motion.rs` 的模块
|
||||
/// 文档有源码行号),在 `view` 里起跑的轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」。
|
||||
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let task = self.route_message(message);
|
||||
self.sync_motion();
|
||||
task
|
||||
}
|
||||
|
||||
/// 把一条消息派发到它的落点(`update` 的正文;拆出来只为让 [`App::sync_motion`] 收尾)。
|
||||
fn route_message(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Boot => self.on_boot(),
|
||||
Message::WindowReady(id) => {
|
||||
self.window = id;
|
||||
// M93:整窗 α 要 Win32 句柄,而 iced 只给 `window::Id`。`window::run` 是唯一能把
|
||||
// `&dyn Window`(实现了 `HasWindowHandle`)拿出来的入口。
|
||||
match id {
|
||||
Some(id) => {
|
||||
window::run(id, super::window_alpha::probe_hwnd).map(Message::WindowHandle)
|
||||
}
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
Message::WindowHandle(hwnd) => {
|
||||
self.hwnd = hwnd;
|
||||
match hwnd {
|
||||
Some(_) => self.apply_window_alpha("boot"),
|
||||
None => {
|
||||
diag!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
|
||||
self.log_appearance("boot", None);
|
||||
}
|
||||
}
|
||||
// M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`,
|
||||
// 先打圆角日志会把 `layered=` 记成 `off`(那正是我们要分辨的那一位)。
|
||||
self.apply_window_corner("boot");
|
||||
Task::none()
|
||||
}
|
||||
Message::SystemTheme(mode) => {
|
||||
let flavor = match mode {
|
||||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||
};
|
||||
diag!("[theme] 系统主题 = {mode:?}");
|
||||
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
|
||||
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
|
||||
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
|
||||
if self.options.theme.is_none() && self.theme_mode == ThemeMode::Auto {
|
||||
self.set_flavor(flavor);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::Frame => self.on_frame(),
|
||||
Message::Heartbeat => self.on_heartbeat(),
|
||||
Message::Window(event) => {
|
||||
match event {
|
||||
window::Event::Resized(size) => {
|
||||
self.width = size.width;
|
||||
// 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。
|
||||
self.height = size.height;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
// M113: import/export —— 拖入整合包(本版唯一的选文件通道;窗口级事件,
|
||||
// 所以入口收起时也照样接得住。细节在 `on_pack_dropped` / `on_pack_hover`)
|
||||
window::Event::FileDropped(path) => return self.on_pack_dropped(path),
|
||||
window::Event::FileHovered(_) => return self.on_pack_hover(true),
|
||||
window::Event::FilesHoveredLeft => return self.on_pack_hover(false),
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::Page(message) => self.on_page(message),
|
||||
Message::Dialog(message) => dialog::on_dialog(self, message),
|
||||
Message::Agent(message) => {
|
||||
// `Esc` 先给弹窗:有弹窗时它属于「关掉这一层」,不属于 AI 栏的收起。
|
||||
if let crate::shell::agent::Message::Key(event) = &message {
|
||||
if is_escape(event) && dialog::escape(self) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
self.on_agent(message)
|
||||
}
|
||||
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
|
||||
Message::Select(nav) => {
|
||||
self.state.navigate(route_of(nav));
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
Message::DragWindow => match self.window {
|
||||
Some(id) => window::drag(id),
|
||||
None => Task::none(),
|
||||
},
|
||||
Message::ResizeStart(direction) => match self.window {
|
||||
Some(id) => window::drag_resize(id, direction),
|
||||
None => Task::none(),
|
||||
},
|
||||
Message::Minimize => match self.window {
|
||||
Some(id) => window::minimize(id, true),
|
||||
None => Task::none(),
|
||||
},
|
||||
Message::ToggleMaximize => match self.window {
|
||||
Some(id) => window::toggle_maximize(id),
|
||||
None => Task::none(),
|
||||
},
|
||||
Message::Close => iced::exit(),
|
||||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||
Message::Demo(message) => {
|
||||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||||
diag!("[gallery] {} pressed", message.id());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动:问窗口句柄 → 让后端发布首屏快照 → 决定主题。
|
||||
///
|
||||
/// **首屏快照在 `Boot` 就抽干**(不是等第一拍):截图通道在第 3 帧落盘,33 ms 的节拍
|
||||
/// 未必来得及到达,等节拍会把空页面拍进图里。
|
||||
fn on_boot(&mut self) -> Task<Message> {
|
||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||
|
||||
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
|
||||
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
|
||||
self.configure_motion();
|
||||
|
||||
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
|
||||
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
|
||||
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
|
||||
// 免得 UI 侧把判据重算一遍、两份口径漂。日后看日志能一眼分辨
|
||||
// 「没配置」与「配了但登录失败」。
|
||||
diag!("{}", unlucky_cli::commands::msa_client_id_log_line());
|
||||
|
||||
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
|
||||
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
|
||||
self.apply_config_theme();
|
||||
|
||||
// M93 外观取证开关(`ULL_APPEARANCE_*`)+ 启动那一行数值日志。
|
||||
// 必须在 `refresh_view` 之前:设置页要画的就是这份状态。
|
||||
self.appearance.apply_env();
|
||||
self.rebuild_ui();
|
||||
self.log_appearance("boot", None);
|
||||
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
|
||||
boot_env::record_boot_log(self);
|
||||
|
||||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||||
if self.options.wants_system_theme() {
|
||||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||
}
|
||||
|
||||
// M104:助手栏的启动尾巴(窗口尺寸 adopt + 「拆出档直接落到独立页」)在 update/agent.rs。
|
||||
// **必须在 RefreshAll 之前**:那次 dispatch 会 `pump → auto_select`,把路由拉到实例详情,
|
||||
// 而 `sync_agent_route` 看到「不在 Agent 页却还是 Detached」就会把档位收回来——顺序反了
|
||||
// 就会出现「`ULL_AGENT_RAIL=detached` 启动后停在实例页」(r2 实测踩到)。
|
||||
self.boot_agent();
|
||||
|
||||
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
|
||||
self.dispatch(AppCommand::RefreshAll);
|
||||
|
||||
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
|
||||
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
|
||||
if let Some(route) = boot_env::env_start_route() {
|
||||
diag!("[boot] ULL_START_ROUTE → {route:?}");
|
||||
self.state.navigate(route);
|
||||
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
// M109:直达内容库时补一次检索(与页面内切换同一个入口)
|
||||
self.ensure_content_loaded();
|
||||
}
|
||||
account::force_account_state(self);
|
||||
// M187:启动失败弹窗的取证夹具(`ULL_DIALOG_FIXTURE`),必须在 `refresh_view` 之前。
|
||||
if let Some(mode) = dialog_fixture::apply(self) {
|
||||
diag!("[boot] ULL_DIALOG_FIXTURE → {mode}");
|
||||
}
|
||||
|
||||
self.auto_select();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
|
||||
// 空闲心跳从这里开始自续(它是唯一能在「没有帧事件」时戳醒 app 的时钟)
|
||||
tasks.push(heartbeat());
|
||||
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
|
||||
fn configure_motion(&mut self) {
|
||||
let pin = boot_env::env_motion_pin();
|
||||
let gpu = matches!(
|
||||
super::backend::effective_kind(),
|
||||
Some(crate::renderer::Kind::Wgpu)
|
||||
);
|
||||
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
|
||||
motion::configure(scale, pin);
|
||||
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
|
||||
diag!(
|
||||
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
|
||||
motion::scale().factor(),
|
||||
pin,
|
||||
gpu,
|
||||
self.options.screenshot.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
|
||||
///
|
||||
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
||||
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
|
||||
fn on_heartbeat(&mut self) -> Task<Message> {
|
||||
// 动效时间轴也吃这一拍:窗口被遮挡 / 最小化 / 合成器不送绘制时,帧一个都不来,而
|
||||
// 「有轨道」又让下面的 1 Hz 推进整段停摆——那会让 `needs_frames()` 永远为真。心跳把
|
||||
// 轨道按单次上限([`components::motion::pump::MAX_STEP_MS`])推几步收尾,最坏的「没有帧」
|
||||
// 情况下 5 拍内自然退订;有帧时它只多推 64 ms/秒,看不出来。
|
||||
motion::advance();
|
||||
if !self.needs_frames() {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
|
||||
self.last_tick = now;
|
||||
self.advance(elapsed);
|
||||
}
|
||||
// M192 真机取证(`ULL_MOTION_FIXTURE_AT`):到点就**真的**导航一次。放在 advance 之后 ⇒
|
||||
// 这一拍是货真价实的空闲态;跳转后由 `update` 收尾的 `sync_motion` 起跑(与点击同一条路)。
|
||||
self.motion_fixture_after_idle();
|
||||
heartbeat()
|
||||
}
|
||||
|
||||
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
|
||||
/// [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
///
|
||||
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
||||
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
|
||||
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
|
||||
motion::advance();
|
||||
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
|
||||
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
|
||||
// `ULL_MOTION_FIXTURE_AT`(真机空闲取证)在的时候**不跑**这一支:两条取证路互斥,
|
||||
// 否则第 1 帧就把页面换成目标页,等 `_AT` 的心跳再跳同一个目标 = 标识没变 = 没有动效。
|
||||
if self.frames <= 2 && boot_env::env_motion_fixture_at().is_none() {
|
||||
self.motion_fixture();
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_tick);
|
||||
if elapsed >= TICK {
|
||||
self.last_tick = now;
|
||||
self.advance(elapsed);
|
||||
}
|
||||
|
||||
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
|
||||
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图回来了:落盘 + 打一行取证日志,然后退出。
|
||||
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||||
let Some(path) = self.options.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
match crate::shot::save(screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
diag!("{}", summary.describe());
|
||||
// M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。
|
||||
diag!(
|
||||
"[shot] screen={} theme={} renderer={} route={:?}",
|
||||
self.options.screen.name(),
|
||||
crate::shot::flavor_slug(&self.ui),
|
||||
super::backend::effective_kind().map_or("?", Kind::name),
|
||||
self.state.route()
|
||||
);
|
||||
}
|
||||
Err(error) => diag!("[shot] 失败:{error}"),
|
||||
}
|
||||
|
||||
iced::exit()
|
||||
}
|
||||
}
|
||||
|
||||
/// 空闲心跳的一拍:睡 1 秒再发 [`Message::Heartbeat`]。
|
||||
///
|
||||
/// 用 `Task::perform` + `std::thread::sleep` 而不是 `iced::time::every`:后者只在 tokio/smol
|
||||
/// 后端提供(本 crate 的执行器是 thread-pool,Cargo.toml 不在 scope)。代价是每拍让线程池里
|
||||
/// 一个线程阻塞 1 秒——按 1 Hz 算,对线程池(默认 = CPU 数)是可接受的占用。
|
||||
fn heartbeat() -> Task<Message> {
|
||||
Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| {
|
||||
Message::Heartbeat
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_wiring;
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 账户页消息的落点(M114):页面只发意图,这里翻成 `AppCommand` 或本地的表单状态写入。
|
||||
//!
|
||||
//! 三条纪律(与 `update.rs` 同源):
|
||||
//!
|
||||
//! 1. **不做乐观回显**:切账户/删账户/登录都只派命令,等 `AccountsChanged` /
|
||||
//! `DeviceLoginChanged` / `AuthlibLoginChanged` 回来才改列表;
|
||||
//! 2. **密码只走一次**:`Submit` 把 `AuthlibRequest` 交给后端后立刻清掉表单里的密码
|
||||
//! (成功失败都清——重试用新输入,不在内存里多留一份凭据);
|
||||
//! 3. **表单态是纯 UI 状态**:输入框改动只写 `App::account_form` + 置脏,不派命令。
|
||||
//!
|
||||
//! ## 返回 `Task`(M114 r2 / 复审 P2-1)
|
||||
//!
|
||||
//! 复制用户码/链接必须把 `iced::clipboard::write` 的 `Task` **交回 runtime**——iced 0.14 的
|
||||
//! `Task` 是惰性 stream,丢掉返回值等于永不执行(日志页的 Copy summary 就是正确写法)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest, DeviceLoginPhase};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::pages::account::{Hint, Message as Account, Panel, Track};
|
||||
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 账户页消息。
|
||||
pub(super) fn on_account(&mut self, message: Account) -> Task<Message> {
|
||||
// 复制是唯一需要**交回 Task** 的分支:先把它的 Task 抓出来,其余分支照旧改状态
|
||||
let clipboard = match &message {
|
||||
Account::Copy(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match message {
|
||||
Account::Open(track) => {
|
||||
self.account_form.open(track);
|
||||
self.account_form.hint = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::ToggleAdd => {
|
||||
self.account_form.section_open = !self.account_form.section_open;
|
||||
self.account_form.hint = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::Close => {
|
||||
self.account_form.close();
|
||||
// 关面板必须把**两条轨**的登录态都收回 Idle(复审 r2 P2-1:只看 `busy()` 时,
|
||||
// `Failed`/`Succeeded` 不在 busy ⇒ 状态仍是非 Idle ⇒ `Page::build` 又把面板
|
||||
// 强制回 Microsoft,按钮成了死按钮)。`CancelDeviceLogin` 在 host 侧本来就
|
||||
// 同时收两条轨并发 Idle,所以判据用「非 Idle」而不是「busy」。
|
||||
if self.state.device_login.phase != DeviceLoginPhase::Idle
|
||||
|| self.state.authlib_login.phase != DeviceLoginPhase::Idle
|
||||
{
|
||||
self.dispatch(AppCommand::CancelDeviceLogin);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::DismissNotice => self.dispatch(AppCommand::DismissAccountNotice),
|
||||
Account::Server(value) => {
|
||||
self.account_form.server = value;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::Login(value) => {
|
||||
self.account_form.login = value;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::Password(value) => {
|
||||
self.account_form.password = value;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::OfflineName(value) => {
|
||||
self.account_form.offline_name = value;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Account::Probe => {
|
||||
let server = self.account_form.server.clone();
|
||||
if !server.trim().is_empty() {
|
||||
self.dispatch(AppCommand::ProbeAuthlibServer(server));
|
||||
}
|
||||
}
|
||||
Account::Submit => match self.account_form.panel {
|
||||
Panel::Offline => {
|
||||
// 名字**不清空**:失败时用户还要看见自己输入的(复审 r1 P2-2 的另一半)
|
||||
let name = self.account_form.offline_name.trim().to_string();
|
||||
if !name.is_empty() {
|
||||
self.dispatch(AppCommand::AddOfflineAccount(name));
|
||||
}
|
||||
}
|
||||
Panel::Authlib => {
|
||||
if self.account_form.authlib_ready() {
|
||||
let request = AuthlibRequest::new(
|
||||
self.account_form.server.clone(),
|
||||
self.account_form.login.clone(),
|
||||
std::mem::take(&mut self.account_form.password),
|
||||
);
|
||||
self.dispatch(AppCommand::AddAuthlibAccount(request));
|
||||
}
|
||||
}
|
||||
Panel::Microsoft => return self.on_account(Account::StartDevice),
|
||||
Panel::None => {}
|
||||
},
|
||||
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
|
||||
Account::StartBrowser => self.dispatch(AppCommand::StartBrowserLogin),
|
||||
Account::Cancel => {
|
||||
self.account_form.close();
|
||||
self.dispatch(AppCommand::CancelDeviceLogin);
|
||||
}
|
||||
Account::Copy(_) => {}
|
||||
Account::Switch(id) => self.dispatch(AppCommand::SwitchAccount(id)),
|
||||
Account::Remove(id) => self.dispatch(AppCommand::RemoveAccount(id)),
|
||||
Account::Relogin(id) => return self.relogin(&id),
|
||||
}
|
||||
self.refresh_view();
|
||||
|
||||
match clipboard {
|
||||
Some(text) => iced::clipboard::write(text),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 「重新登录」按**账户类型**分派(复审 r1 P2-3)。
|
||||
///
|
||||
/// - 微软轨:走授权码流(原路);
|
||||
/// - 第三方轨:第三方站没有「一键重登」——预填服务器地址与登录名,让用户补密码;
|
||||
/// - 本地档案:没有服务器账户可重登 ⇒ 明确说明,不假装能做。
|
||||
fn relogin(&mut self, id: &str) -> Task<Message> {
|
||||
let Some(account) = self
|
||||
.state
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.id == id)
|
||||
.cloned()
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.account_form.hint = None;
|
||||
match account.kind {
|
||||
AccountKind::Microsoft => {
|
||||
self.account_form.panel = Panel::Microsoft;
|
||||
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
|
||||
self.dispatch(AppCommand::StartDeviceLogin);
|
||||
}
|
||||
AccountKind::Authlib => {
|
||||
self.account_form.open(Track::Authlib);
|
||||
self.account_form.server = account.server_url.clone();
|
||||
self.account_form.login = account.login_name.clone();
|
||||
self.account_form.password.clear();
|
||||
self.account_form.hint = Some(Hint::AuthlibReauth);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
AccountKind::Offline => {
|
||||
self.account_form.hint = Some(Hint::OfflineNoRelogin);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_ACCOUNT_STATE` 的落地(**只在 `--screenshot` 下生效**)。
|
||||
///
|
||||
/// 为什么需要:设备码过期要等 15 分钟、表单要手点两下,截图通道等不到自然发生的状态。
|
||||
pub(super) fn force_account_state(app: &mut App) {
|
||||
if !app.has_screenshot() {
|
||||
return;
|
||||
}
|
||||
let Ok(state) = std::env::var("ULL_ACCOUNT_STATE") else {
|
||||
return;
|
||||
};
|
||||
match state.trim().to_lowercase().as_str() {
|
||||
// 设备码已过期:起点往前挪一小时(倒计时自己算出来就是 0)
|
||||
"expired" => {
|
||||
app.state.device_login_started =
|
||||
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
|
||||
}
|
||||
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
|
||||
//
|
||||
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
|
||||
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
|
||||
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
|
||||
"msa-fallback" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
app.state.apply_device_login(
|
||||
unlucky_presenter::dto::DeviceLoginView::waiting(
|
||||
"ABCD-EFGH",
|
||||
"https://www.microsoft.com/link",
|
||||
900,
|
||||
"",
|
||||
)
|
||||
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
|
||||
);
|
||||
}
|
||||
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
|
||||
"authlib-failed" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
app.account_form.server = "https://example.invalid/api/yggdrasil/".to_string();
|
||||
app.account_form.login = "alex@example.com".to_string();
|
||||
}
|
||||
// 离线档案失败态:面板打开(横幅由 mock 的 notice 场景给)
|
||||
"notice" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Offline;
|
||||
app.account_form.offline_name = "ab".to_string();
|
||||
}
|
||||
// 第三方站表单:直接把面板打开并预填地址
|
||||
"authlib" | "authlib-form" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
if app.account_form.server.trim().is_empty() {
|
||||
app.account_form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
|
||||
}
|
||||
app.account_form.login = "steve@example.com".to_string();
|
||||
app.account_form.password = "not-a-real-password".to_string();
|
||||
}
|
||||
// 「关掉之后」:mock 给的是 failed 种子,这里按一次 Close(走真实的
|
||||
// `on_account` → `CancelDeviceLogin` 通道),出「面板真的关了」的对照图。
|
||||
"closed" => {
|
||||
let _ = app.on_account(crate::pages::account::Message::Close);
|
||||
}
|
||||
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
|
||||
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
|
||||
"msa-entry" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
}
|
||||
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
||||
"add" => {
|
||||
app.account_form.section_open = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,303 @@
|
||||
//! **树测试②(M119 验收核心)**:账户页消费 presenter 的账户列表与登录事件。
|
||||
//!
|
||||
//! 后端是 UI 侧的 [`AccountRig`]:它把 `AppCommand` 翻成**与真 host 同形状**的 `CoreEvent`
|
||||
//! (`AccountsChanged` / `DeviceLoginChanged` / `AccountNoticeChanged`),数据在内存里。
|
||||
//! 于是这条测试钉住的是「**接线**」——用户点一下之后,命令出去、事件回来、页面重算、横幅出现;
|
||||
//! 抓不住的是 `core::auth::AccountStore` 的落盘(那条由 presenter 自己的
|
||||
//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,原因见 `presenter/rig.rs` 的模块文档)。
|
||||
//!
|
||||
//! 覆盖任务书第 3 条与第 4 条的账户那一半:
|
||||
//!
|
||||
//! | 断言 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | 添加离线档案 → 列表出现一行 | `AddOfflineAccount` 没派发 / 事件没回流 / 页面没重建 |
|
||||
//! | 切换当前账号 → 「当前」标记换人 | `SwitchAccount` 没派发(M114 的 `active` 标记是页面唯一来源)|
|
||||
//! | 删除 → 列表回到空态 | `RemoveAccount` 没派发 |
|
||||
//! | 设备码「进行中可见 + 可取消」 | `StartDeviceLogin` / `CancelDeviceLogin` 没接线(只剩转圈)|
|
||||
//! | 失败态:名字不合法 / 凭据失效都给**具名**错误 | 失败被吞掉(复审 r1 P2-2 的老毛病)|
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountNoticeCode, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::presenter::rig::AccountRig;
|
||||
use crate::pages::account::model::{AuthlibPanel, DevicePanel};
|
||||
use crate::pages::account::{Form, Message as Account, Panel, Track};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::App;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App,后端换成**账户 rig**(其余分支照旧走 `MockBackend` 的能力就够)。
|
||||
fn with_rig() -> App {
|
||||
App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(AccountRig::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
fn seed(app: &mut App, event: CoreEvent) {
|
||||
app.state.apply(AppEvent::Core(event));
|
||||
app.refresh_view();
|
||||
}
|
||||
|
||||
/// **添加 → 切换 → 删除**(任务书第 3 条,一条端到端)。
|
||||
///
|
||||
/// 抓得住:三条命令任何一条没派发 / 事件没回流 / `refresh_view` 没带上账户页。
|
||||
/// 抓不住:真实账户库的落盘(见模块文档)。
|
||||
#[test]
|
||||
fn an_offline_profile_is_added_switched_and_removed_end_to_end() {
|
||||
let mut app = with_rig();
|
||||
assert!(app.account.is_empty(), "出厂是空态");
|
||||
|
||||
// ① 添加离线档案:走真消息(点轨道卡 → 填名字 → 提交)
|
||||
let _ = app.on_account(Account::Open(Track::Offline));
|
||||
assert_eq!(app.account_form.panel, Panel::Offline);
|
||||
// 表单输入也走真消息(纯 UI 态,只写 `App::account_form` + 置脏)
|
||||
let _ = app.on_account(Account::OfflineName("Steve".to_string()));
|
||||
assert!(
|
||||
app.account_form.offline_ready(),
|
||||
"3–16 位字母:按钮该是亮的"
|
||||
);
|
||||
let _ = app.on_account(Account::Submit);
|
||||
|
||||
assert_eq!(app.account.rows.len(), 1, "列表出现一行(不是静默)");
|
||||
assert_eq!(app.account.rows[0].name, "Steve");
|
||||
assert_eq!(
|
||||
app.account.notice.as_ref().map(|notice| notice.code),
|
||||
Some(AccountNoticeCode::Added),
|
||||
"页面横幅给出的是一次性结果"
|
||||
);
|
||||
let id = app.account.rows[0].id.clone();
|
||||
|
||||
// ② 切换当前账号
|
||||
let _ = app.on_account(Account::Switch(id.clone()));
|
||||
assert_eq!(
|
||||
app.account.active_row().map(|row| row.name.as_str()),
|
||||
Some("Steve"),
|
||||
"「当前」标记跟着切换走"
|
||||
);
|
||||
|
||||
// ③ 删除
|
||||
let _ = app.on_account(Account::Remove(id));
|
||||
assert!(app.account.rows.is_empty(), "删除后回到空态");
|
||||
assert_eq!(
|
||||
app.account.notice.as_ref().map(|notice| notice.code),
|
||||
Some(AccountNoticeCode::Removed)
|
||||
);
|
||||
// 两个横幅文案都有词(中英各一条,不是硬编码)
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
assert!(!Catalog::of(lang).text(Key::AccountNoticeRemoved).is_empty());
|
||||
}
|
||||
// 整棵树在三种状态下都能构图
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 设备码流程:**指令发出 + 进行中可见 + 可取消**(任务书第 3 条的后半,真实网络不要求)。
|
||||
#[test]
|
||||
fn the_device_code_flow_is_started_visible_and_cancellable() {
|
||||
let mut app = with_rig();
|
||||
assert_eq!(app.account.device, DevicePanel::Hidden);
|
||||
|
||||
let _ = app.on_account(Account::StartDevice);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, countdown, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
assert_eq!(app.account.panel, Panel::Microsoft, "面板要切到微软那条轨");
|
||||
let _ = app.view();
|
||||
|
||||
let _ = app.on_account(Account::Cancel);
|
||||
assert_eq!(app.account.device, DevicePanel::Hidden, "取消要真的收掉");
|
||||
assert_eq!(
|
||||
app.state.device_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// 失败态①:**未实现 / 名字不合法** ⇒ 页面横幅给出结构化码(不是静默无语)。
|
||||
#[test]
|
||||
fn an_invalid_profile_name_surfaces_a_named_failure() {
|
||||
let mut app = with_rig();
|
||||
let _ = app.on_account(Account::Open(Track::Offline));
|
||||
// 名字不合法时**发送键是灰的**(与 host 同一份判据),所以这里直接派命令:
|
||||
// 抓的是「host 真的回了一个结构化失败码」这条链路,而不是按钮的禁用态。
|
||||
assert!(!app.account_form.offline_ready());
|
||||
app.dispatch(unlucky_presenter::dto::AppCommand::AddOfflineAccount(
|
||||
"ab".to_string(),
|
||||
));
|
||||
|
||||
assert!(app.account.rows.is_empty(), "不合法的名字不入列表");
|
||||
let notice = app.account.notice.as_ref().expect("必须有横幅");
|
||||
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
|
||||
assert!(notice.failure, "失败档(配色不同)");
|
||||
assert_eq!(notice.subject, "ab", "横幅要带上是哪个名字");
|
||||
// 文案走 catalog,中英各一条
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
assert!(!Catalog::of(lang)
|
||||
.text(Key::AccountNoticeOfflineNameInvalid)
|
||||
.is_empty());
|
||||
}
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 失败态②:**账户凭据失效** ⇒ 第三方轨面板进入 `Failed(BadCredentials)`(只带原因码)。
|
||||
///
|
||||
/// 为什么只断言原因码:M114 r3 起面板类型里**没有** `message`/`detail` 字段(原文只进 presenter
|
||||
/// 日志),页面据码选 catalog 文案。这条测试与「页面画得出文案」之间的桥梁是
|
||||
/// `pages::account::login::failure_body_key`(同一 crate 内的穷尽 match,缺一条编译不过)。
|
||||
#[test]
|
||||
fn expired_credentials_surface_a_named_failure_code() {
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::AuthlibLoginChanged(AuthlibLoginView::failed_as(
|
||||
LoginFailure::BadCredentials,
|
||||
"服务器返回 401:凭据已失效(token=…已脱敏)",
|
||||
)),
|
||||
);
|
||||
app.account_form.open(Track::Authlib);
|
||||
app.refresh_view();
|
||||
|
||||
assert_eq!(
|
||||
app.account.authlib,
|
||||
AuthlibPanel::Failed {
|
||||
failure: LoginFailure::BadCredentials
|
||||
},
|
||||
"凭据失效要落成具名原因码,而不是静默回 Idle"
|
||||
);
|
||||
// 原因码对应的那句译文两套语言都得有
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let text = Catalog::of(lang).text(Key::AccountFailureBadCredentials);
|
||||
assert!(!text.trim().is_empty(), "{} 缺这条失败文案", lang.tag());
|
||||
}
|
||||
// 原文不许出现在页面的任何可视文本里(M114 的凭据红线)
|
||||
assert!(
|
||||
!format!("{:?}", app.account).contains("服务器返回 401"),
|
||||
"后端原文不许进页面模型"
|
||||
);
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- M114 的既有回归闸
|
||||
|
||||
/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。
|
||||
///
|
||||
/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制
|
||||
/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin`
|
||||
/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。
|
||||
///
|
||||
/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。
|
||||
/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按
|
||||
/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。
|
||||
#[test]
|
||||
fn close_really_closes_the_login_panel_on_both_tracks() {
|
||||
// ① 微软轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView {
|
||||
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
|
||||
message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(),
|
||||
failure: Some(LoginFailure::NotWhitelisted),
|
||||
..DeviceLoginView::new()
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示");
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉");
|
||||
assert_eq!(
|
||||
app.state.device_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
|
||||
// ② 微软轨:成功态(同样不在 busy 里)
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")),
|
||||
);
|
||||
assert_eq!(app.account.panel, Panel::Microsoft);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉");
|
||||
|
||||
// ③ 第三方轨:失败态
|
||||
let mut app = headless();
|
||||
seed(
|
||||
&mut app,
|
||||
CoreEvent::AuthlibLoginChanged(AuthlibLoginView {
|
||||
phase: unlucky_presenter::dto::DeviceLoginPhase::Failed,
|
||||
server: "https://example.invalid/api/yggdrasil/".to_string(),
|
||||
server_name: String::new(),
|
||||
message: "服务器返回 HTTP 404".to_string(),
|
||||
failure: Some(LoginFailure::NotAuthlibServer),
|
||||
account_name: String::new(),
|
||||
}),
|
||||
);
|
||||
app.account_form.open(Track::Authlib);
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.account.panel,
|
||||
Panel::Authlib,
|
||||
"第三方轨失败态面板要显示"
|
||||
);
|
||||
let _ = app.on_account(Account::Close);
|
||||
assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉");
|
||||
assert_eq!(
|
||||
app.state.authlib_login.phase,
|
||||
unlucky_presenter::dto::DeviceLoginPhase::Idle
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。
|
||||
///
|
||||
/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。
|
||||
/// 抓不住:真实 dispatch 的副作用(那要真后端)。
|
||||
#[test]
|
||||
fn the_form_state_follows_the_page_messages() {
|
||||
let mut form = Form::new();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
form.open(Track::Authlib);
|
||||
assert_eq!(form.panel, Panel::Authlib);
|
||||
form.password = "hunter2".to_string();
|
||||
form.close();
|
||||
assert_eq!(form.panel, Panel::None);
|
||||
assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉");
|
||||
}
|
||||
|
||||
/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。
|
||||
#[test]
|
||||
fn the_add_section_starts_collapsed() {
|
||||
let mut form = Form::new();
|
||||
assert!(!form.section_open);
|
||||
form.section_open = true;
|
||||
assert!(form.section_open);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 助手栏消息的落点(M104 建,M119 接线):三档状态迁移 / 输入 / 键盘 / **presenter 四契约**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
|
||||
//! 助手栏有十几个变体,塞回主文件必破线。
|
||||
//!
|
||||
//! ## 分工:视图态 vs presenter 契约(M119 之后的分界线)
|
||||
//!
|
||||
//! | 消息 | 落到哪 |
|
||||
//! |---|---|
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`)+ `agent_mark_read` |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去)+ `agent_mark_read` |
|
||||
//! | `Edit` | 视图态(草稿),并同步进 presenter 的草稿(`agent_set_draft`) |
|
||||
//! | `Submit` | [`AgentHost::submit`] → `agent_submit`;失败**不留白**(具名错误进横幅) |
|
||||
//! | `Stop` | `AppCommand::StopAgent`(真后端才收敛得出结果;同步 rig 下是 no-op) |
|
||||
//! | `Confirm(accept)` | [`AgentHost::confirm`] → `agent_confirm`(YOLO 下 presenter 自己放行并记审计) |
|
||||
//! | `Events(..)` | [`AgentHost::apply`] → `AppState::apply`(**事件回流的唯一落点**) |
|
||||
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径(`Tab` 的环要在流式中多一格,`busy` 从 rail 取) |
|
||||
//!
|
||||
//! **会话内容一行都不在这里攒**:`transcript` / `busy` / `unread` / `confirm` 全在 presenter 的
|
||||
//! `AgentRail` 上(M119 删掉了 M104 的自持副本)。
|
||||
//!
|
||||
//! ## 事件为什么绕一圈消息再回来
|
||||
//!
|
||||
//! `agent_submit` / `agent_confirm` 的端口是同步的,但**事件走队列**(见
|
||||
//! `app/state/presenter/transport.rs`):抽队列的那一步必须和用户点击走同一条漏斗,否则两处各改
|
||||
//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻
|
||||
//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key as TextKey};
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::app::MIN_SIZE;
|
||||
use crate::app::WINDOW_SIZE;
|
||||
use crate::shell::agent::demo::{self, Fixture};
|
||||
use crate::shell::agent::{self, Focus, RailState};
|
||||
|
||||
use super::super::state::presenter::agent::SubmitOutcome;
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
|
||||
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
|
||||
// M106 口径:文案走**生效语言**的目录,不再恒英文
|
||||
let catalog = Catalog::of(self.lang());
|
||||
|
||||
// 只有**几何**会变的几支才需要重建页面模型(`pages::Model` 里没有会话内容:会话在
|
||||
// presenter 的 rail 上,右栗自己那棵树每次 `view()` 都从 rail 现读)。复审 P3-9 的教训:
|
||||
// 每敲一个字符都重建整张模型是白克隆。
|
||||
let geometry_changed = matches!(
|
||||
message,
|
||||
agent::Message::Expand
|
||||
| agent::Message::Collapse
|
||||
| agent::Message::Detach
|
||||
| agent::Message::Dock
|
||||
| agent::Message::DragTo(_)
|
||||
| agent::Message::DragEnd
|
||||
| agent::Message::Escape
|
||||
| agent::Message::Activate
|
||||
| agent::Message::Key(_)
|
||||
);
|
||||
|
||||
match message {
|
||||
agent::Message::Expand => {
|
||||
self.agent.expand();
|
||||
// 「展开即读」是 presenter 的语义(`agent_mark_read`),壳只负责在正确的时刻调它
|
||||
self.agent_host.mark_read();
|
||||
}
|
||||
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
|
||||
agent::Message::Detach => {
|
||||
self.agent.detach();
|
||||
self.agent_host.mark_read();
|
||||
self.state.navigate(Route::Agent);
|
||||
}
|
||||
agent::Message::Dock => {
|
||||
self.agent.dock();
|
||||
self.agent_host.mark_read();
|
||||
self.leave_agent_page();
|
||||
}
|
||||
agent::Message::DragStart => self.agent.drag_start(),
|
||||
agent::Message::DragTo(x) => self.agent.drag_to(self.width, x),
|
||||
agent::Message::DragEnd => self.agent.drag_end(),
|
||||
agent::Message::Edit(action) => {
|
||||
self.agent.edit(action);
|
||||
// 草稿同步给 presenter(切形态不丢;它也是 rail 上唯一允许躺原文的字段)
|
||||
self.agent_host.set_draft(self.agent.draft.text());
|
||||
}
|
||||
agent::Message::Submit => self.submit_agent(),
|
||||
agent::Message::Stop => self.dispatch(AppCommand::StopAgent),
|
||||
agent::Message::Confirm(accept) => {
|
||||
self.agent_host.confirm(accept);
|
||||
self.drain_agent();
|
||||
}
|
||||
agent::Message::Events(events) => {
|
||||
for event in events {
|
||||
self.agent_host.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
agent::Message::Tab { shift } => {
|
||||
let rail = self.agent_host.rail();
|
||||
let (busy, pending) = (rail.busy, rail.confirm.is_some());
|
||||
self.agent.step_focus(shift, busy, pending);
|
||||
}
|
||||
agent::Message::Activate => self.activate_focused(catalog),
|
||||
agent::Message::Key(event) => return self.on_agent_key(event),
|
||||
}
|
||||
|
||||
// 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上
|
||||
self.sync_confirm_focus();
|
||||
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
|
||||
self.sync_agent_presenter();
|
||||
if geometry_changed {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 发送:草稿 → `agent_submit` → 抽事件。
|
||||
///
|
||||
/// 三种结局各自有落点:发出去(清草稿)/ 后端失败(具名横幅,见 [`App::agent_failure`])/
|
||||
/// presenter 拒收(日志一行——这三种情形正常情况下被禁用态挡住,够不着)。
|
||||
fn submit_agent(&mut self) {
|
||||
let text = self.agent.draft.text();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.agent_host.submit(text) {
|
||||
SubmitOutcome::Sent => {
|
||||
self.agent.clear_draft();
|
||||
self.agent_host.set_draft(String::new());
|
||||
self.drain_agent();
|
||||
}
|
||||
SubmitOutcome::Failed(failure) => {
|
||||
diag!("[agent] 提交失败({})", failure.code());
|
||||
}
|
||||
SubmitOutcome::Rejected => {
|
||||
diag!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 抽干 presenter 的事件队列,**经既有 Message 通道**回流(见模块文档末节)。
|
||||
pub(crate) fn drain_agent(&mut self) {
|
||||
let events = self.agent_host.drain();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = self.on_agent(agent::Message::Events(events));
|
||||
}
|
||||
|
||||
/// 把「栏可见吗」写回 presenter(`navigate` + `prefs_mut` 两个窄入口,见 `AgentHost`)。
|
||||
pub(crate) fn sync_agent_presenter(&mut self) {
|
||||
let on_page = self.state.route() == &Route::Agent;
|
||||
let collapsed = self.agent.rail == RailState::Collapsed;
|
||||
self.agent_host.sync_visibility(on_page, collapsed);
|
||||
}
|
||||
|
||||
/// 失败横幅要显示的东西(`None` = 没有错误)。
|
||||
///
|
||||
/// 两个来源都取**展示输入**:具名原因码 → catalog 译文;或 presenter 已脱敏的这一轮原因。
|
||||
/// 壳在这一层不做任何脱敏(那是 presenter 的`scrub_text`),也不拼中英(那是 catalog 的事)。
|
||||
pub(crate) fn agent_failure(&self) -> Option<agent::Failure<'_>> {
|
||||
let catalog = self.lang().catalog();
|
||||
if let Some(failure) = self.agent_host.last_failure() {
|
||||
return Some(agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body: catalog.text(failure.body_key()),
|
||||
});
|
||||
}
|
||||
self.agent_host
|
||||
.rail()
|
||||
.error
|
||||
.as_deref()
|
||||
.map(|body| agent::Failure {
|
||||
title: TextKey::AgentErrorTitle,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。
|
||||
///
|
||||
/// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把
|
||||
/// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳
|
||||
/// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格
|
||||
/// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`;
|
||||
/// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。
|
||||
///
|
||||
/// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是
|
||||
/// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。
|
||||
///
|
||||
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
|
||||
fn sync_confirm_focus(&mut self) {
|
||||
let pending = self.agent_host.rail().confirm.is_some();
|
||||
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
|
||||
if pending && !on_confirm {
|
||||
self.agent.focus = Focus::ConfirmAllow;
|
||||
diag!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
|
||||
} else if !pending && on_confirm {
|
||||
self.agent.focus = Focus::Composer;
|
||||
self.agent.keyboard_mode = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
|
||||
///
|
||||
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
|
||||
/// 而路由还能从三条**别的**路径进 `Route::Agent`——左侧导航第五项、`pages::Message::Navigate`、
|
||||
/// `ULL_START_ROUTE=agent`。脱钩的后果:从导航进独立页时头部出现「Open assistant」(那是
|
||||
/// 「展开右栗」的意思)、标题旁挂着不化的未读胶囊、`Esc` 也收不走页面。
|
||||
///
|
||||
/// 两条对向规则:
|
||||
/// - **在**独立页上 ⇒ `Detached`(顺带按「拆出即读」清未读);
|
||||
/// - **不在**独立页上而档位还是 `Detached`(用户从页上点了别的导航)⇒ 会话收回右栗(`dock()`),
|
||||
/// 否则会话会「两头都没有」:页走了、栏还是 0 宽。
|
||||
///
|
||||
/// 调用点只有一个:`App::refresh_view` 的开头。每一次路由变化都会经过它,于是**模型算宽度时**
|
||||
/// 与**画头部动作时**读到的永远是同一份档位。
|
||||
pub(crate) fn sync_agent_route(&mut self) {
|
||||
let on_page = self.state.route() == &Route::Agent;
|
||||
match (on_page, self.agent.rail) {
|
||||
(true, RailState::Detached) | (false, RailState::Collapsed | RailState::Expanded) => {}
|
||||
(true, _) => self.agent.detach(),
|
||||
(false, RailState::Detached) => self.agent.dock(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时助手栏要做的三件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。
|
||||
///
|
||||
/// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸
|
||||
/// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗
|
||||
/// 时页面还按 1180 排版(窄窗里内容被裁)。
|
||||
/// 2. **取证夹具**(`ULL_AGENT_*`):先落档位,再**跑一轮真提交**——夹具不造消息,消息只能
|
||||
/// 由 presenter 产生(M119 的口径,见 `pages/agent/demo.rs`)。
|
||||
/// 3. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在
|
||||
/// 独立页里,启动后不该停在实例页。
|
||||
pub(super) fn boot_agent(&mut self) {
|
||||
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
);
|
||||
self.width = window_size.0;
|
||||
self.height = window_size.1;
|
||||
|
||||
if let Some((ui, fixture)) = demo::from_env() {
|
||||
self.agent = ui;
|
||||
// 先让 presenter 知道「栏现在看得见吗」,再跑提交,未读口径才对
|
||||
self.sync_agent_presenter();
|
||||
match fixture {
|
||||
Fixture::None => {}
|
||||
Fixture::Turn | Fixture::Unread => {
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
}
|
||||
Fixture::Armed => {
|
||||
// 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`:
|
||||
// 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则)
|
||||
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
|
||||
self.drain_agent();
|
||||
let rail = self.agent_host.rail();
|
||||
let (busy, pending) = (rail.busy, rail.confirm.is_some());
|
||||
self.agent.step_focus(false, busy, pending);
|
||||
}
|
||||
Fixture::Focused => {
|
||||
let rail = self.agent_host.rail();
|
||||
let (busy, pending) = (rail.busy, rail.confirm.is_some());
|
||||
self.agent.step_focus(false, busy, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
|
||||
diag!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
// 直达独立页时不要再被「自动选中第一条实例」拉回详情页。
|
||||
self.auto_selected = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。
|
||||
///
|
||||
/// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么)
|
||||
/// 会散在订阅表里;这里一眼能看完。
|
||||
fn on_agent_key(&mut self, event: iced::keyboard::Event) -> Task<Message> {
|
||||
let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let message = match key {
|
||||
Key::Named(key::Named::Escape) => agent::Message::Escape,
|
||||
// 折叠档没有会话动作可巡航,`Tab` 不该被助手栏吃掉
|
||||
Key::Named(key::Named::Tab) if self.agent.rail == RailState::Collapsed => {
|
||||
return Task::none();
|
||||
}
|
||||
Key::Named(key::Named::Tab) => agent::Message::Tab {
|
||||
shift: modifiers.shift(),
|
||||
},
|
||||
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
|
||||
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
|
||||
//
|
||||
// M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。
|
||||
// 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸,
|
||||
// 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。
|
||||
Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
|
||||
self.on_agent(message)
|
||||
}
|
||||
|
||||
/// `Esc` / `⌄`:展开 → 收起;独立页 → 收回右栗并离开 `Route::Agent`。
|
||||
fn collapse_agent(&mut self) {
|
||||
let was_detached = self.agent.rail == RailState::Detached;
|
||||
self.agent.collapse();
|
||||
if was_detached {
|
||||
self.leave_agent_page();
|
||||
}
|
||||
}
|
||||
|
||||
/// 离开 AI 独立页:能回退就回退(`Detach` 是压栈的),否则回实例页。
|
||||
fn leave_agent_page(&mut self) {
|
||||
if self.state.route() != &Route::Agent {
|
||||
return;
|
||||
}
|
||||
if !self.state.back() {
|
||||
self.state.navigate(Route::Instances);
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘 `Enter`:激活软焦点上的按钮(`Tab` 环的顺序见 [`Focus`])。
|
||||
///
|
||||
/// 焦点在 [`Focus::Composer`] 上时**什么都不做**:那一条路径由输入框自己的 `key_binding`
|
||||
/// 负责(按 `Tab` 走到输入框并不等于真的把 iced 焦点交过去,两次发送会打架)。
|
||||
fn activate_focused(&mut self, catalog: Catalog) {
|
||||
// 鼠标刚点过输入框(或从没按过 `Tab`)⇒ 这个 Enter 不归助手栏管
|
||||
if !self.agent.keyboard_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let intent = match (self.agent.rail, self.agent.focus) {
|
||||
(RailState::Collapsed, Focus::Collapse) => Some(agent::Message::Expand),
|
||||
(RailState::Expanded, Focus::Collapse) => Some(agent::Message::Collapse),
|
||||
(RailState::Expanded, Focus::Detach) | (RailState::Detached, Focus::Detach) => {
|
||||
Some(if self.agent.rail == RailState::Detached {
|
||||
agent::Message::Dock
|
||||
} else {
|
||||
agent::Message::Detach
|
||||
})
|
||||
}
|
||||
(_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)),
|
||||
(_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)),
|
||||
(_, Focus::Send) => Some(agent::Message::Submit),
|
||||
(_, Focus::Stop) => Some(agent::Message::Stop),
|
||||
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
|
||||
// 显式列成 None 只是为了让编译器确认这份 intent 表是穷尽的。
|
||||
(RailState::Collapsed, Focus::Detach)
|
||||
| (RailState::Detached, Focus::Collapse)
|
||||
| (_, Focus::Composer) => None,
|
||||
};
|
||||
|
||||
if let Some(message) = intent {
|
||||
let _ = self.on_agent(message);
|
||||
} else {
|
||||
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
|
||||
diag!(
|
||||
"[agent] Enter on focus={} handled by the composer",
|
||||
self.agent.focus.name()
|
||||
);
|
||||
let _ = catalog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! **树测试①(M119 验收核心)**:AI 右栏在**真实 presenter 状态**下渲染。
|
||||
//!
|
||||
//! 走的是同一条用户路径:`Message::Agent(Submit)` → `AgentHost::submit` → `Presenter::agent_submit`
|
||||
//! → transport → 事件队列 → `Message::Agent(Events)` → `Presenter::apply` → `AgentRail`。
|
||||
//! **没有一条断言看本地数组**:`transcript` 这类壳自己的会话副本 M119 已经删了,能断言的只剩
|
||||
//! presenter 的 rail,而 rail 正是右栏画的东西(`pages/agent::spec` 从它借)。
|
||||
//!
|
||||
//! 覆盖任务书点名的四件事:
|
||||
//!
|
||||
//! | 断言 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | 提交后 rail 出现「你 + 助手」两条、正文来自事件的流式增量 | 提交没接到 presenter、或右栏还在画样本会话 |
|
||||
//! | `busy` 翻真 → 收敛后翻假 | 事件没回流、或流式标记没收敛(输入框会永远禁用) |
|
||||
//! | 标记已读后未读归零 | `agent_mark_read` 没接、或未读口径没跟着 rail |
|
||||
//! | 三种失败都给**具名**横幅 | 失败被吞掉(M104 的「静默无语」)|
|
||||
|
||||
use iced::widget::text_editor;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use iced::keyboard::key::{Code, Named, Physical};
|
||||
use iced::keyboard::{key, Location, Modifiers};
|
||||
|
||||
use crate::app::state::presenter::transport::Script;
|
||||
use crate::app::state::presenter::AgentHost;
|
||||
use crate::app::Message;
|
||||
use crate::cli::Options;
|
||||
use crate::shell::agent::{Focus, Message as AgentMessage, RailState};
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::App;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥 + **本脚本的** presenter 句柄)。
|
||||
fn app_with(script: Script) -> App {
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.agent_host = AgentHost::with_script(script);
|
||||
// 换句柄 = 可见性还没写回去;`refresh_view` 是那条单点同步
|
||||
app.refresh_view();
|
||||
app
|
||||
}
|
||||
|
||||
/// 走真消息提交(用户路径:填草稿 → 点发送)。
|
||||
fn submit(app: &mut App, text: &str) {
|
||||
app.agent.draft = text_editor::Content::with_text(text);
|
||||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||||
}
|
||||
|
||||
/// **提交 → 事件回流 → 右栏出现这一轮 → 标记已读归零**(任务书第 2 条)。
|
||||
///
|
||||
/// 抓得住:①`Submit` 没接 `agent_submit`(rail 空);②事件没经 Message 通道回流(rail 只有半截);
|
||||
/// ③`expand` 没调 `agent_mark_read`(未读不清);④右栏读的还是壳自己的副本。
|
||||
/// 抓不住:气泡在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/`)。
|
||||
#[test]
|
||||
fn a_submitted_turn_lands_in_the_rail_and_reading_it_zeroes_unread() {
|
||||
let mut app = app_with(Script::Qa);
|
||||
// 出厂是折叠档 ⇒ presenter 认为「用户没在看着」,到达的助手消息应计未读
|
||||
assert_eq!(app.agent.rail, RailState::Collapsed);
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "空会话没有未读");
|
||||
|
||||
submit(&mut app, "Which instance should I start?");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
assert_eq!(
|
||||
rail.messages.len(),
|
||||
2,
|
||||
"一条「你」+ 一条助手(事件产生,不是本地回显)"
|
||||
);
|
||||
assert_eq!(rail.messages[0].role, ChatRole::User);
|
||||
assert_eq!(rail.messages[0].text, "Which instance should I start?");
|
||||
assert_eq!(rail.messages[1].role, ChatRole::Assistant);
|
||||
assert!(
|
||||
rail.messages[1].text.contains("Fabric"),
|
||||
"正文来自流式增量:{:?}",
|
||||
rail.messages[1].text
|
||||
);
|
||||
assert!(!rail.messages[1].streaming, "Qa 脚本会收敛");
|
||||
assert!(!rail.busy, "收敛之后不忙(发送键要能再按)");
|
||||
assert_eq!(rail.unread, 1, "折叠态到达的一条助手消息 = 一条未读");
|
||||
|
||||
// 右栏画的正是这份 rail(`pages/agent::spec` 直接借它)——构图不 panic
|
||||
let _ = app.view();
|
||||
|
||||
// 打开栏 = 读过:`agent_mark_read`
|
||||
let _ = app.update(Message::Agent(AgentMessage::Expand));
|
||||
assert_eq!(app.agent_host.rail().unread, 0, "标记已读后未读归零");
|
||||
assert_eq!(app.agent_host.rail().messages.len(), 2, "已读不改会话");
|
||||
}
|
||||
|
||||
/// **busy 正确翻转**:流式中忙 → 后端收敛后不忙。
|
||||
///
|
||||
/// 抓得住:`busy` 还由壳自己算(那它不会跟 rail 走);`Events` 通道被当成「只追加消息」而丢掉
|
||||
/// `MessageFinished`(输入框会永远禁用——这是最要命的坏法之一)。
|
||||
#[test]
|
||||
fn busy_flips_with_the_backend_events() {
|
||||
let mut app = app_with(Script::Streaming);
|
||||
submit(&mut app, "hello");
|
||||
assert!(app.agent_host.rail().busy, "流式没收敛前输入要禁用");
|
||||
|
||||
// 后端收敛:一条 `MessageFinished` 经同一条 Message 通道回流
|
||||
let index = app.agent_host.rail().messages.len() - 1;
|
||||
let _ = app.update(Message::Agent(AgentMessage::Events(vec![AppEvent::Agent(
|
||||
AgentEvent::MessageFinished { index },
|
||||
)])));
|
||||
assert!(!app.agent_host.rail().busy, "收敛之后必须能再发");
|
||||
}
|
||||
|
||||
/// 写操作确认:rail 上有待确认 ⇒ 忙;裁决后收敛并记一条审计。
|
||||
#[test]
|
||||
fn a_pending_write_op_is_visible_and_decidable() {
|
||||
let mut app = app_with(Script::WriteOp);
|
||||
submit(&mut app, "把下载并发改小");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
let request = rail.confirm.as_ref().expect("要挂一个待确认请求");
|
||||
assert_eq!(request.tool, "set_config");
|
||||
assert!(
|
||||
rail.busy,
|
||||
"等确认也算忙(否则用户能在一轮写操作中间再发一句)"
|
||||
);
|
||||
let _ = app.view();
|
||||
|
||||
let _ = app.update(Message::Agent(AgentMessage::Confirm(true)));
|
||||
assert!(app.agent_host.rail().confirm.is_none(), "裁决后清空");
|
||||
assert!(!app.agent_host.rail().busy);
|
||||
assert_eq!(app.agent_host.audit_len(), 1, "裁决要进审计(YOLO 下也是)");
|
||||
}
|
||||
|
||||
/// 失败态①:**AI 端点不可达** ⇒ 具名横幅(不是静默,也不是把后端原文糊上屏)。
|
||||
#[test]
|
||||
fn an_unreachable_endpoint_shows_a_named_error() {
|
||||
let mut app = app_with(Script::Unreachable);
|
||||
submit(&mut app, "hello");
|
||||
|
||||
let failure = app.agent_failure().expect("提交失败必须给横幅");
|
||||
// 文案跟着**生效语言**走(`LangState` 在这台机器上探到 zh-CN,硬编码英文会假红)
|
||||
let effective = app.lang().catalog();
|
||||
assert_eq!(failure.title, Key::AgentErrorTitle, "标题是 catalog 键");
|
||||
assert_eq!(failure.body, effective.text(Key::AgentErrorUnavailable));
|
||||
assert!(!failure.body.trim().is_empty());
|
||||
assert!(
|
||||
app.agent_host.rail().messages.is_empty(),
|
||||
"失败不产生消息:不许假装有回复"
|
||||
);
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
/// 失败态②:**未实现路由** ⇒ 另一句具名错误(两种失败不能糊成一句)。
|
||||
#[test]
|
||||
fn an_unimplemented_route_shows_its_own_named_error() {
|
||||
let mut app = app_with(Script::NotImplemented);
|
||||
submit(&mut app, "hello");
|
||||
|
||||
let failure = app.agent_failure().expect("必须给横幅");
|
||||
let effective = app.lang().catalog();
|
||||
assert_eq!(failure.body, effective.text(Key::AgentErrorNotImplemented));
|
||||
// 两条原因码要两句话(中英两套都得分开),不是一句万能错误
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let catalog = Catalog::of(lang);
|
||||
assert_ne!(
|
||||
catalog.text(Key::AgentErrorNotImplemented),
|
||||
catalog.text(Key::AgentErrorUnavailable),
|
||||
"{} 下两条原因码糊成一句了",
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败态③:**这一轮回复中途断流** ⇒ 横幅用 presenter 已脱敏的那句原因,已到的增量保留。
|
||||
#[test]
|
||||
fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
|
||||
let mut app = app_with(Script::FailsMidStream);
|
||||
submit(&mut app, "读一下日志");
|
||||
|
||||
let rail = app.agent_host.rail();
|
||||
// 用户 + 助手(流式占位)+ 系统条目(`Failed` 会额外追加一条,错误也要能回翻)
|
||||
assert_eq!(rail.messages.len(), 3, "断流不撤销已到的增量");
|
||||
assert!(
|
||||
rail.messages[1].text.contains("日志"),
|
||||
"已到的增量要留着:{:?}",
|
||||
rail.messages[1].text
|
||||
);
|
||||
assert!(!rail.busy, "断流要收敛掉流式标记");
|
||||
let failure = app.agent_failure().expect("`Failed` 事件要立横幅");
|
||||
assert!(
|
||||
failure.body.contains("read timeout"),
|
||||
"横幅正文是 presenter 脱敏后的原因:{}",
|
||||
failure.body
|
||||
);
|
||||
}
|
||||
|
||||
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
|
||||
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
|
||||
///
|
||||
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
|
||||
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
|
||||
#[test]
|
||||
fn the_default_backend_reports_itself_as_simulated() {
|
||||
let app = app_with(Script::Qa);
|
||||
assert!(
|
||||
app.agent_host.is_simulated(),
|
||||
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
|
||||
);
|
||||
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
|
||||
let session = crate::shell::agent::Session {
|
||||
agent: &app.agent,
|
||||
rail: app.agent_host.rail(),
|
||||
failure: app.agent_failure(),
|
||||
simulated: app.agent_host.is_simulated(),
|
||||
};
|
||||
assert!(session.simulated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_syncs_the_draft_into_the_presenter() {
|
||||
let mut app = app_with(Script::Qa);
|
||||
app.agent.draft = text_editor::Content::with_text("half typed");
|
||||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('!')),
|
||||
)));
|
||||
assert_eq!(
|
||||
app.agent_host.rail().draft,
|
||||
app.agent.draft.text(),
|
||||
"编辑器里是什么,rail 上就该是什么(同一个串,不是它自己的副本)"
|
||||
);
|
||||
assert!(app.agent_host.rail().draft.contains('!'), "改动真的进去了");
|
||||
// 发送成功后草稿两边都清空
|
||||
let _ = app.update(Message::Agent(AgentMessage::Submit));
|
||||
assert!(app.agent.draft_is_blank(), "编辑器清空");
|
||||
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
|
||||
}
|
||||
|
||||
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
|
||||
fn press(named: key::Named) -> iced::keyboard::Event {
|
||||
let key = key::Key::Named(named);
|
||||
iced::keyboard::Event::KeyPressed {
|
||||
key: key.clone(),
|
||||
modified_key: key,
|
||||
physical_key: Physical::Code(Code::Enter),
|
||||
location: Location::Standard,
|
||||
modifiers: Modifiers::default(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
|
||||
///
|
||||
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
|
||||
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
|
||||
///
|
||||
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
|
||||
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
|
||||
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
|
||||
#[test]
|
||||
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
|
||||
let mut app = app_with(Script::WriteOp);
|
||||
submit(&mut app, "把下载并发改小");
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"先要有一个待确认请求"
|
||||
);
|
||||
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
|
||||
assert_eq!(
|
||||
app.agent.focus,
|
||||
Focus::ConfirmAllow,
|
||||
"焦点要落在可操作元素上"
|
||||
);
|
||||
assert!(
|
||||
!app.agent.keyboard_mode,
|
||||
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
|
||||
);
|
||||
|
||||
// ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
assert!(app.agent.keyboard_mode, "Tab 之后才算 armed");
|
||||
assert_eq!(
|
||||
app.agent.focus,
|
||||
Focus::ConfirmAllow,
|
||||
"arming 的那一次不移动焦点(否则 Enter 会反着裁决)"
|
||||
);
|
||||
|
||||
// ③ Enter = 允许
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_none(),
|
||||
"Enter 必须真的裁决掉"
|
||||
);
|
||||
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
|
||||
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
|
||||
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
|
||||
|
||||
// ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个
|
||||
submit(&mut app, "那把并发改回来");
|
||||
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
|
||||
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||||
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
|
||||
assert_eq!(app.agent_host.audit_len(), 2);
|
||||
}
|
||||
|
||||
/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。
|
||||
///
|
||||
/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了
|
||||
/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。
|
||||
///
|
||||
/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的
|
||||
/// `Activate` 绕到 `keyboard_mode` 闸之外。
|
||||
/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。
|
||||
#[test]
|
||||
fn a_stray_space_or_enter_never_approves_a_pending_write() {
|
||||
let mut app = app_with(Script::WriteOp);
|
||||
submit(&mut app, "把下载并发改小");
|
||||
assert!(app.agent_host.rail().confirm.is_some());
|
||||
assert!(!app.agent.keyboard_mode, "待确认不该自动 arm");
|
||||
|
||||
// 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格
|
||||
let _ = app.update(Message::Agent(AgentMessage::Edit(
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('a')),
|
||||
)));
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"未 armed 时空格不许批准写操作"
|
||||
);
|
||||
assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计");
|
||||
|
||||
// 回车同理:未 armed 时它归编辑器(发送键),不归确认条
|
||||
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
|
||||
assert!(
|
||||
app.agent_host.rail().confirm.is_some(),
|
||||
"未 armed 时回车不许批准写操作"
|
||||
);
|
||||
assert_eq!(app.agent_host.audit_len(), 0);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! M116:窗口圆角的下发落点(`impl App::apply_window_corner`)。
|
||||
//!
|
||||
//! ## 为什么单独成文件
|
||||
//!
|
||||
//! 圆角与整窗 α 是**两条独立的管线**,只是都必须拿到 HWND、都在同一个时点下发:
|
||||
//!
|
||||
//! | 管线 | 判据 | 落点 | 键 |
|
||||
//! |---|---|---|---|
|
||||
//! | 整窗 α(D36) | `platform::effective_window_alpha` | `SetLayeredWindowAttributes` | `appearance.window_opacity` |
|
||||
//! | 圆角(M116) | `platform::plan_corner` | `DwmSetWindowAttribute` | `appearance.window_corner` |
|
||||
//!
|
||||
//! 合在一个函数里会让「α 变了要不要重设圆角」这种问题变成实现细节;分开之后两个判据各自可测。
|
||||
//!
|
||||
//! ## 日志为什么是**一行、六栏**
|
||||
//!
|
||||
//! `corner=policy=… source=dwm|square os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
|
||||
//!
|
||||
//! 用户报的现象是「别的窗口都有圆角,只有我们的没有」。没有这一行,事后无法区分
|
||||
//! 「没生效」「生效了但看不见」「这台系统根本没有这个能力」;`frame` / `layered` 两栏则是
|
||||
//! 「圆角为什么不生效」的两个现场读数:`frame=`(窗口形状)与 `layered=`(整窗 α 引入的分层位)。
|
||||
//!
|
||||
//! ⚠️ **产品窗口不是 `WS_POPUP`**(M116 r4 复审订正):winit 在 `decorations = false` 下给的是
|
||||
//! `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU`(可缩放时再加 `WS_THICKFRAME` 与 min/max 键),
|
||||
//! `WS_POPUP` 只在**带 owner** 的弹出窗才加;无边框观感来自 `WM_NCCALCSIZE` 把客户区铺满。
|
||||
//! 所以真进程日志里 `frame=` 应是 `overlapped`(关掉 min/max 则 `other`),**永远不会是 `popup`**。
|
||||
//! 真正让系统忽略圆角请求的是**整窗 α 补的 `WS_EX_LAYERED`**(`layered=on`)。
|
||||
//!
|
||||
//! `radius=system` 是刻意的:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
|
||||
//! (M116 取消了 8/12/16 固定档)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::platform;
|
||||
use crate::platform::win32::corner::{self, CornerApplied};
|
||||
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
|
||||
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 把当前圆角策略下发到窗口,并打一行可分辨的日志。
|
||||
///
|
||||
/// ## 去重(M116 r1 复审 P2-4)
|
||||
///
|
||||
/// 设置页的滑条(整窗 α / 面板 α / 暗化)每动一下都会走一次外观变更,而<b>圆角与那些键无关</b>。
|
||||
/// 判定完全没变时这里**直接返回**:不动窗口、不打日志。判据是
|
||||
/// [`corner::needs_redispatch`](纯函数,有「同一策略 N 次只记 1 行」的测试),
|
||||
/// 指纹只含「策略 / 来源 / 原因码 / 有没有句柄」,不含只有真下发才拿得到的读数。
|
||||
///
|
||||
/// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。
|
||||
/// 句柄从无到有会让指纹变化 ⇒ 首次拿到 HWND 时必然重下一次。
|
||||
pub(crate) fn apply_window_corner(&mut self, context: &str) {
|
||||
let policy = self.appearance.window_corner();
|
||||
let plan = platform::plan_corner(
|
||||
policy,
|
||||
platform::current_windows_version(),
|
||||
self.appearance.backend,
|
||||
);
|
||||
// 整窗 α < 1 ⇒ `window_alpha::dispatch` 会给窗口补 `WS_EX_LAYERED`;系统因此忽略圆角请求
|
||||
// (tower 裁定 A / D47)。这里只记**事实**:α 是否把我们自己的分层位打开了。
|
||||
let alpha_layered = self.appearance.window_opacity < 1.0;
|
||||
let suppressed_by = alpha_layered.then_some("window_alpha");
|
||||
let next = CornerApplied::new(policy, &plan, self.hwnd.is_some(), alpha_layered);
|
||||
if !corner::needs_redispatch(self.corner_applied.as_ref(), &next) {
|
||||
return;
|
||||
}
|
||||
self.corner_applied = Some(next);
|
||||
|
||||
// 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。
|
||||
// 关键的三栏必须在同一行:`pref=`(请求发出去了)、`layered=`(分层位在不在,来自
|
||||
// 整窗 α)、`frame=`(窗口形状 = `overlapped`,不是 `popup`)——否则以后只能猜是哪一条。
|
||||
let (outcome, style) = match self.hwnd {
|
||||
Some(hwnd) => (apply_corner(hwnd, &plan).describe(), style_summary(hwnd)),
|
||||
None => (
|
||||
CornerOutcome::NoWindow.describe(),
|
||||
"frame=? layered=?".to_owned(),
|
||||
),
|
||||
};
|
||||
diag!(
|
||||
"{}",
|
||||
corner::format_line(context, &plan, policy, &outcome, &style, suppressed_by)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 启动期的取证环境变量解析(`ULL_START_ROUTE` / 动效三个开关)。
|
||||
//!
|
||||
//! 从 `app/update.rs` 原样搬出(那个文件在 M105/M106/M109/M113 之后贴着 400 行硬线):
|
||||
//! **纯函数、无状态**,只是取值表长到了十条。M192 在这里加的是动效那一组
|
||||
//! ([`motion_scale`] / [`env_motion_pin`] / [`env_motion_fixture`]):档位策略只有这一处,
|
||||
//! 帧泵自己不读环境变量。
|
||||
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
|
||||
///
|
||||
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
|
||||
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
|
||||
pub(super) fn env_start_route() -> Option<Route> {
|
||||
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
|
||||
"instances" => Some(Route::Instances),
|
||||
"download" => Some(Route::Download),
|
||||
"logs" => Some(Route::Logs),
|
||||
"settings" => Some(Route::Settings),
|
||||
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
|
||||
"agent" => Some(Route::Agent),
|
||||
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
|
||||
"content" => Some(Route::Content),
|
||||
// M114:账户页的 D30 矩阵要能直达(`cli.rs` 不在本 mission 的 scope)
|
||||
"accounts" => Some(Route::Accounts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 动效档位与相位钉住(M192):帧泵**只在产品的 `on_boot` 里定档**。
|
||||
///
|
||||
/// ## 为什么档位在这里算
|
||||
///
|
||||
/// 三个输入只有启动这一拍齐全:①是不是截图通道(`--screenshot`)②实际渲染后端
|
||||
/// (`backend::effective_kind()`,不是 `--renderer` 的请求值)③`ULL_MOTION` 的强制值。
|
||||
/// 帧泵自己(`components::motion`)不读环境变量,它只接受一个 [`MotionScale`]——这样动效策略
|
||||
/// 只有一处,测试也不必摆环境变量。
|
||||
///
|
||||
/// | 条件 | 档位 | 为什么 |
|
||||
/// |---|---|---|
|
||||
/// | `ULL_MOTION=off` | `NONE` | 一键关(排障 / 想量基线时用) |
|
||||
/// | 截图通道,且没给 `ULL_MOTION_AT` | `NONE` | D30:出图不许有动画(否则第 3 帧抓到中间态,图就不可复现) |
|
||||
/// | CPU 渲染(`tiny-skia`) | `HALF` | 时长减半 = 帧数减半。位移是**布局量**,CPU 档最贵的就是逐帧重排 |
|
||||
/// | 其余(`wgpu`) | `STANDARD` | —— |
|
||||
/// | `ULL_MOTION=half` / `full` | 强制 | 覆盖上表(出图矩阵要在 GPU 上量同一段动效) |
|
||||
///
|
||||
/// 「减少动效」的用户开关**还不存在**(`core/src/setting` 里没有 animation key,见报告 follow-up),
|
||||
/// 所以本函数只认上面这些来源。
|
||||
pub(super) fn motion_scale(screenshot: bool, gpu: bool, pinned: bool) -> MotionScale {
|
||||
match std::env::var("ULL_MOTION").ok().as_deref().map(str::trim) {
|
||||
Some("off") | Some("none") => return MotionScale::NONE,
|
||||
Some("half") => return MotionScale::HALF,
|
||||
Some("full") => return MotionScale::STANDARD,
|
||||
_ => {}
|
||||
}
|
||||
if pinned {
|
||||
// 取证相位优先于「截图必须静」,否则多帧序列图一张都出不来。
|
||||
return MotionScale::STANDARD;
|
||||
}
|
||||
if screenshot {
|
||||
return MotionScale::NONE;
|
||||
}
|
||||
if gpu {
|
||||
MotionScale::STANDARD
|
||||
} else {
|
||||
MotionScale::HALF
|
||||
}
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_AT=<ms>`:把动画时钟钉在某一毫秒(多帧序列图取证)。
|
||||
///
|
||||
/// 语义与 `ULL_APPEARANCE_*` / `ULL_DIALOG_FIXTURE` 一致:只影响这一次进程。没有它,第 3 帧落在
|
||||
/// 动画的哪个相位取决于这台机器的渲染速度 —— 那样「0% / 50% / 100% 三张图」是抓阄抓出来的。
|
||||
pub(super) fn env_motion_pin() -> Option<f64> {
|
||||
std::env::var("ULL_MOTION_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|ms| ms.is_finite() && *ms >= 0.0)
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE`:第 1 帧之后**真的**做一次状态变化(多帧序列图靠它)。
|
||||
///
|
||||
/// 为什么要制造变化:页面主体的入场只在「内容标识变了」时起跑(`motion::observe`),而截图通道
|
||||
/// 从第一帧起画的就是同一页——不制造一次真实的路由变化,就没有可拍的动效。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum MotionFixture {
|
||||
/// 跳到一个一级页(走「换页」那一档)。
|
||||
Route(Route),
|
||||
/// 「列表 → 详情」:先回列表(第 1 帧),再点进第一条实例(第 2 帧)。
|
||||
Detail,
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE=<instances|download|logs|settings|content|detail>`。
|
||||
pub(super) fn env_motion_fixture() -> Option<MotionFixture> {
|
||||
Some(match std::env::var("ULL_MOTION_FIXTURE").ok()?.trim() {
|
||||
"instances" => MotionFixture::Route(Route::Instances),
|
||||
"download" => MotionFixture::Route(Route::Download),
|
||||
"logs" => MotionFixture::Route(Route::Logs),
|
||||
"settings" => MotionFixture::Route(Route::Settings),
|
||||
"content" => MotionFixture::Route(Route::Content),
|
||||
"detail" => MotionFixture::Detail,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ULL_MOTION_FIXTURE_AT=<秒>`:启动后第 N 次心跳(≈第 N 秒)**真的**导航一次。
|
||||
///
|
||||
/// ## 为什么需要它(真机空闲取证)
|
||||
///
|
||||
/// 复审 r1 要求「空闲 ≥10 s 后点一次导航轨,看 `[tick] motion=` 涨多少」。本机工作区所在会话
|
||||
/// 没有前台窗口(`GetForegroundWindow()==0`),合成鼠标事件(`mouse_event` 与
|
||||
/// `PostMessage(WM_LBUTTONDOWN)` 都试过)递不进 iced 的输入层,所以这里换一条**同构**的路:
|
||||
/// 由 1 Hz 心跳(空闲态本来就有的消息)在第 N 秒做一次真实的路由跳转。
|
||||
///
|
||||
/// 两条路在 `update` 之后**完全同一段代码**:消息 → `sync_motion`(起跑)→ `subscription()`
|
||||
/// (同一拍看见轨道)→ `frames()`。差别只在「消息是谁产生的」,而这正是 P1 要验的那一段。
|
||||
pub(super) fn env_motion_fixture_at() -> Option<u32> {
|
||||
std::env::var("ULL_MOTION_FIXTURE_AT")
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|beats| *beats > 0)
|
||||
}
|
||||
|
||||
/// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
|
||||
///
|
||||
/// ## 为什么这一拍只能「先记、后落」
|
||||
///
|
||||
/// `.ULL/logs` 的落点由 `platform::data_dir` 的三模式决定(要读配置、follow `redirect.json`),
|
||||
/// 那件事只有 presenter 的 `HostEnv` 做过。app 层拿不到它,于是这里只 `record`
|
||||
/// (core 侧缓冲,最多 200 行);presenter **构造 `HostEnv` 的那一刻**就调
|
||||
/// `launcher_log::set_dir`(M188 补上的那一半),缓冲一次刷进 `<.ULL>/logs/launcher.log`。
|
||||
///
|
||||
/// 效果正是用户投诉要的那份东西:他「双击 → 启动 → 崩 → 找日志」时,`launcher.log` 里从
|
||||
/// **启动这一拍**(主题 / 语言 / 路由 / 窗口 / 后端)一直写到退出,而不是只有 launch 那一段。
|
||||
///
|
||||
/// M188 之后边界也变了:`boot.rs` / `boot/fallback.rs` / `state/*` 那些 `eprintln!` 改走
|
||||
/// [`crate::app::diag::emit`](stderr + 文件双写),同样靠这条缓冲接住——所以「一局都没开就崩」
|
||||
/// 的进程也会留下 `launcher.log`。
|
||||
pub(crate) fn record_boot_log(app: &super::super::state::App) {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
launcher_log::record("boot", "app boot");
|
||||
launcher_log::record(
|
||||
"boot",
|
||||
&format!(
|
||||
"theme={} lang={:?} route={:?} window={:.0}x{:.0} screenshot={} instances={}",
|
||||
app.ui().name(),
|
||||
app.lang(),
|
||||
app.state.route(),
|
||||
app.width,
|
||||
app.height,
|
||||
app.has_screenshot(),
|
||||
app.state.instances.len(),
|
||||
),
|
||||
);
|
||||
launcher_log::record("boot", &unlucky_cli::commands::msa_client_id_log_line());
|
||||
// 后端这一拍也要在日志里:`[probe]` / `[backend]` 那几行由 `renderer::acceptance` 打到
|
||||
// stderr(那个模块不在 M188 的 scope),事故之后翻日志的人至少要看得出「这次用的是哪个后端」。
|
||||
launcher_log::record(
|
||||
"boot",
|
||||
&format!(
|
||||
"backend request={} effective={} renderer_fallback={}",
|
||||
app.options.backend.name(),
|
||||
super::super::backend::effective_backend(app.options.backend).name(),
|
||||
std::env::var(crate::renderer::FALLBACK_ENV).unwrap_or_else(|_| "-".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 节拍:推进一拍 / 派发指令 / 抽干事件(M119 从 `update.rs` 拆出来守 400 行硬线;纯搬不改)。
|
||||
//!
|
||||
//! ## 为什么这三件必须在一起
|
||||
//!
|
||||
//! `advance` → `pump` → `apply` → `refresh_view` 是同一条链:时间推进、事件抽干、状态落地、
|
||||
//! 视图重建。拆开看每一段都无害,合起来才是「presenter 的异步事件在 UI 侧怎么被消费」的答案。
|
||||
//!
|
||||
//! ## 两个时钟的相位(M90 r3 / P2-2 的老账,代码里有细节)
|
||||
//!
|
||||
//! | 场景 | 时钟 | 周期 |
|
||||
//! |---|---|---|
|
||||
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms |
|
||||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s |
|
||||
//!
|
||||
//! ## M119 增加了什么
|
||||
//!
|
||||
//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干,
|
||||
//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
|
||||
|
||||
use super::super::state::TICK_MS;
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
|
||||
///
|
||||
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
|
||||
pub(in crate::app) fn advance(&mut self, elapsed: Duration) {
|
||||
self.backend.tick(elapsed);
|
||||
self.pump();
|
||||
|
||||
self.state
|
||||
.toasts
|
||||
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
|
||||
|
||||
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
|
||||
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
|
||||
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
|
||||
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
|
||||
let font_status = crate::fonts::install::status();
|
||||
if font_status != self.font_seen {
|
||||
self.font_seen = font_status;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
|
||||
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
|
||||
self.hydrate_java_scan();
|
||||
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
|
||||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||||
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
|
||||
let bucket_before = self.ticks / 30;
|
||||
self.ticks += ticks;
|
||||
let bucket_after = self.ticks / 30;
|
||||
|
||||
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
|
||||
//
|
||||
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
|
||||
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
|
||||
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
|
||||
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
|
||||
let poll_due = self.ticks >= self.config_due_at;
|
||||
if poll_due {
|
||||
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
|
||||
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
|
||||
}
|
||||
let reloaded =
|
||||
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
|
||||
if reloaded {
|
||||
diag!("[settings] 配置文件变了,重读主题 + 外观");
|
||||
self.apply_config_theme();
|
||||
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
|
||||
self.reload_appearance();
|
||||
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
|
||||
self.hydrate_ai_settings();
|
||||
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
|
||||
self.hydrate_java_settings();
|
||||
}
|
||||
|
||||
if self.view_dirty {
|
||||
self.refresh_view();
|
||||
}
|
||||
|
||||
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
|
||||
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
|
||||
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
||||
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
||||
diag!(
|
||||
"[tick] n={} frames={} refreshes={} clock={} motion={}",
|
||||
self.ticks,
|
||||
self.frames,
|
||||
self.refreshes,
|
||||
if self.needs_frames() {
|
||||
"frames"
|
||||
} else {
|
||||
"idle-1hz"
|
||||
},
|
||||
// M192:动效时间轴已结算的帧数(每秒一行 ⇒ 这一列的增长速度就是动画的实际帧率,
|
||||
// 也是「动画期间重绘成本」的记账口径,见报告)
|
||||
crate::app::components::motion::samples()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
|
||||
pub(in crate::app) fn dispatch(&mut self, command: AppCommand) {
|
||||
self.backend.dispatch(command);
|
||||
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
|
||||
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
|
||||
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
|
||||
self.pump();
|
||||
}
|
||||
|
||||
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
|
||||
pub(in crate::app) fn pump(&mut self) {
|
||||
// M119:AI 助手的事件队列与 core 的事件队列**同拍**抽干(顺序无关:两个队列互不依赖)。
|
||||
// 抽出来的这批事件经 `Message::Agent(Events)` 回流,见 `update/agent.rs`。
|
||||
self.drain_agent();
|
||||
|
||||
let events = self.backend.drain_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut clean_scan = false;
|
||||
for event in events {
|
||||
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
|
||||
if matches!(
|
||||
&event,
|
||||
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
|
||||
pack
|
||||
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
|
||||
) {
|
||||
clean_scan = true;
|
||||
}
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
self.auto_select();
|
||||
self.refresh_view();
|
||||
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
|
||||
if clean_scan {
|
||||
self.continue_import_after_clean_scan();
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
|
||||
///
|
||||
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
|
||||
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
|
||||
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
|
||||
pub(in crate::app) fn auto_select(&mut self) {
|
||||
if self.auto_selected || self.state.route() != &Route::Instances {
|
||||
return;
|
||||
}
|
||||
let Some(first) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.auto_selected = true;
|
||||
self.state.navigate(Route::Detail(first));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 内容库页的消息落点(M109):`pages::Message::Content(..)` 的穷尽分发。
|
||||
//!
|
||||
//! ## 两件事分开(与 `update/download.rs` 同一条纪律)
|
||||
//!
|
||||
//! | 意图 | 谁改状态 | 派指令吗 |
|
||||
//! |---|---|---|
|
||||
//! | 关键词草稿 / 选中项目 / 目标实例 | `App::content`(页面内选择) | 不派 |
|
||||
//! | 检索条件(类型/来源/排序/版本/加载器/关键词提交/翻页/重试) | `AppState::content`(presenter) | 派 `SearchContent` |
|
||||
//! | 装入 | 谁都不改 | 派 `InstallContent` |
|
||||
//!
|
||||
//! 「装入」不改本地状态是关键:装上的证据是 `InstallProgress` 事件回流到安装面板
|
||||
//! (`pages/download::install_panel`),页面自己不做乐观回显。
|
||||
//!
|
||||
//! ## 为什么条件写进 presenter 再派
|
||||
//!
|
||||
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
|
||||
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
|
||||
ExportRequestView,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 进入内容库页时补一次检索(首个用户动作不该是「再点一下搜索」)。
|
||||
///
|
||||
/// 判据是 `AppState::content` 里还没有结果、也没有请求在飞:重复进出不重复打网络。
|
||||
pub(super) fn ensure_content_loaded(&mut self) {
|
||||
if self.state.content.results.is_some() || self.state.content.pending {
|
||||
return;
|
||||
}
|
||||
let query = ContentQuery::first(self.content.kind);
|
||||
self.search_content(query);
|
||||
}
|
||||
|
||||
/// 页面消息(穷尽分发:漏一支报 E0004)。
|
||||
pub(super) fn on_content(&mut self, message: pages::content::Message) -> Task<Message> {
|
||||
match message {
|
||||
pages::content::Message::SetKind(kind) => {
|
||||
// 换类型 = 换一份条件(facet 全变):选中项作废,条件回到该类型的首页
|
||||
self.content.kind = kind;
|
||||
self.content.selected = None;
|
||||
let mut query = ContentQuery::first(kind);
|
||||
// 保留用户已经选好的来源/排序/版本/加载器:换成默认值会让筛选条自己跳回去
|
||||
query.source = self.state.content.query.source;
|
||||
query.sort = self.state.content.query.sort;
|
||||
query.game_version = self.state.content.query.game_version.clone();
|
||||
query.loader = self.state.content.query.loader;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::QueryChanged(text) => {
|
||||
self.content.draft = text;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::Search => {
|
||||
let mut query = self.state.content.query.clone();
|
||||
query.text = self.content.draft.trim().to_string();
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::SetSource(source) => {
|
||||
if source == ContentSource::Curseforge && !self.curseforge_available() {
|
||||
// 筛选条那条本来就是禁用的;这里再挡一次(防绕过 UI 的消息)
|
||||
return Task::none();
|
||||
}
|
||||
self.with_query(|query| query.source = source);
|
||||
}
|
||||
pages::content::Message::SetSort(sort) => self.with_query(|query| query.sort = sort),
|
||||
pages::content::Message::SetVersion(version) => {
|
||||
self.with_query(|query| query.game_version = version);
|
||||
}
|
||||
pages::content::Message::SetLoader(loader) => {
|
||||
self.with_query(|query| query.loader = loader)
|
||||
}
|
||||
pages::content::Message::Select(id) => {
|
||||
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
|
||||
self.content.selected = Some(id.clone());
|
||||
self.state.content.request_detail(&id);
|
||||
if let Some(request) = self.content_detail_request() {
|
||||
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::SetTarget(id) => {
|
||||
self.content.target = Some(id);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
pages::content::Message::LoadMore => {
|
||||
let next = self.state.content.query.next_page();
|
||||
self.search_content(next);
|
||||
}
|
||||
pages::content::Message::Retry => {
|
||||
let mut query = self.state.content.query.clone();
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
pages::content::Message::Install => {
|
||||
if let Some(request) = self.content_install_request() {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
|
||||
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
|
||||
pages::content::Message::RetryFailed => {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
if let Some(request) = model.retry_install_request() {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M113: import/export —— 导入/导出区(细节在 `on_pack`)
|
||||
pages::content::Message::Pack(message) => self.on_pack(message),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
|
||||
///
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
|
||||
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
|
||||
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
Some(pages::content::request_export(
|
||||
&self.content.pack,
|
||||
&instance,
|
||||
))
|
||||
}
|
||||
|
||||
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
|
||||
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
|
||||
self.state
|
||||
.navigate(unlucky_presenter::router::Route::Content);
|
||||
self.on_pack(pages::content::PackMessage::Dropped(path));
|
||||
self.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 拖拽悬停状态(有文件在窗口上方时高亮入口)。
|
||||
pub(super) fn on_pack_hover(&mut self, hover: bool) -> Task<Message> {
|
||||
// 状态没变就不重画:winit 对同一批文件会连发几次 hover 事件
|
||||
let changed = self.content.pack.hover != hover;
|
||||
self.on_pack(pages::content::PackMessage::Hover(hover));
|
||||
if changed {
|
||||
self.refresh_view();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 改一个筛选字段并**立刻重查**(第一页)。
|
||||
fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) {
|
||||
let mut query = self.state.content.query.clone();
|
||||
change(&mut query);
|
||||
query.offset = 0;
|
||||
self.search_content(query);
|
||||
}
|
||||
|
||||
/// 提交一次检索:条件先落进 presenter(迟到事件的判据),再派指令。
|
||||
fn search_content(&mut self, query: ContentQuery) {
|
||||
self.state.set_content_query(query.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::SearchContent(query));
|
||||
}
|
||||
|
||||
/// CF 源可用吗(结果里带回的那一位;还没拉过 ⇒ `false`)。
|
||||
fn curseforge_available(&self) -> bool {
|
||||
self.state
|
||||
.content
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.curseforge)
|
||||
}
|
||||
|
||||
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
|
||||
///
|
||||
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
|
||||
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
|
||||
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
let hit = model.selected_hit()?;
|
||||
let target = model
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| Some(&target.id) == model.target.as_ref());
|
||||
Some(ContentDetailRequest {
|
||||
project: hit.id.clone(),
|
||||
source: hit.source,
|
||||
game_version: target.map(|target| target.mc_version.clone()),
|
||||
loader: target.map(|target| target.loader),
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
|
||||
///
|
||||
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
|
||||
///
|
||||
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
|
||||
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
|
||||
fn content_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
if !model.can_install() {
|
||||
// 有原因就打印出来(取证用);没有选中项时什么都不做
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
diag!("[content] 装入被挡下:{reason}");
|
||||
}
|
||||
if model.blockers.contains(&Blocker::NeedsPipeline) {
|
||||
diag!("[content] 整合包装入未实现(M111)");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let hit = model.selected_hit()?;
|
||||
let target = model
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| Some(&target.id) == model.target.as_ref())?;
|
||||
Some(ContentInstallRequest {
|
||||
project: hit.id.clone(),
|
||||
title: hit.title.clone(),
|
||||
// 整合包走不到这里:`ContentKind::Modpack` 的 `dir()` 是 `None` ⇒ 必然带
|
||||
// `Blocker::NeedsPipeline` ⇒ `can_install()` 为假。所以这里就是「单文件」那一类。
|
||||
kind: model.kind,
|
||||
source: hit.source,
|
||||
instance: target.id.clone(),
|
||||
game_version: target.mc_version.clone(),
|
||||
loader: target.loader,
|
||||
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
|
||||
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
|
||||
//!
|
||||
//! ## 两条口径(与原来同一句)
|
||||
//!
|
||||
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
|
||||
//! 状态,不进 presenter;
|
||||
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
|
||||
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
//!
|
||||
//! ## M160 加的那条纪律
|
||||
//!
|
||||
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
|
||||
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
|
||||
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
if let Some(request) = self.pack_import_request() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::PickPage(page) => {
|
||||
self.content.pack.pick_page = page;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::TogglePick(path) => {
|
||||
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
|
||||
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
|
||||
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
|
||||
// (所以"一次都没点过"仍然等于 `KeepAll`)。
|
||||
let choice = self.content.pack.pick_choice(&path);
|
||||
let flipped = match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||||
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
|
||||
};
|
||||
self.content.pack.pick_choices.insert(path, flipped);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickKeepAll => {
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickOverwriteAll => {
|
||||
self.content.pack.pick_overwrite_all = true;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ConfirmImport => {
|
||||
let decisions = self
|
||||
.content
|
||||
.pack
|
||||
.decisions(self.state.pack.import.scan.as_ref());
|
||||
if let Some(request) = self.pack_import_request_with(decisions) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::CancelImport => {
|
||||
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
|
||||
if let Some(request) =
|
||||
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
|
||||
{
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检干净(没有冲突)时直接往下导。
|
||||
///
|
||||
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
|
||||
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
|
||||
/// 结论也只会是"保留用户的"。
|
||||
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
|
||||
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
|
||||
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
|
||||
fn pack_import_request(&self) -> Option<ImportRequestView> {
|
||||
self.pack_import_request_with(ConflictDecisionsView::default())
|
||||
}
|
||||
|
||||
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
|
||||
fn pack_import_request_with(
|
||||
&self,
|
||||
decisions: ConflictDecisionsView,
|
||||
) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 弹窗消息的落点:关闭 / 展开 / 导出 / 打开文件夹 / 复制路径。
|
||||
//!
|
||||
//! 只有两类副作用:**剪贴板**(返回 `Task`,必须交回 runtime)与**弹 Toast**(就地推)。
|
||||
//! 导出与打开文件夹是同步的本地动作(写一个 zip / 拉起一个 `explorer`),耗时都在毫秒级,
|
||||
//! 不为它们单开线程——真要慢(网络盘)也是用户能感知的一次点击。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::dialog::{export, Message as DialogMessage};
|
||||
use crate::app::state::App;
|
||||
|
||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
app.close_dialog();
|
||||
return Task::none();
|
||||
};
|
||||
let done = export::export(&card.report, app.lang());
|
||||
// 结论**画在卡片里**:导出这种「必须看见落到哪」的结果不能只靠 Toast
|
||||
// (app-iced 的 Toast 至今没有渲染点)。成败按 `write_bundle` 的真实结果判,
|
||||
// 不看「launcher_logs 有没有值」——那只是入参齐全度(r1 复审 P3-2)。
|
||||
app.state.toasts.push(
|
||||
if done.ok {
|
||||
ToastKind::Success
|
||||
} else {
|
||||
ToastKind::Error
|
||||
},
|
||||
done.text.clone(),
|
||||
);
|
||||
if let Some(state) = &mut app.dialog_card {
|
||||
state.outcome = Some(done.text);
|
||||
}
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Secondary | DialogMessage::Dismiss => {
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
app.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Toggle => {
|
||||
app.toggle_dialog_detail();
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::OpenFolder => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let problem = export::open_folder(&card.report, app.lang());
|
||||
if !problem.is_empty() {
|
||||
if let Some(state) = &mut app.dialog_card {
|
||||
state.outcome = Some(problem.clone());
|
||||
}
|
||||
app.state.toasts.push(ToastKind::Error, problem);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::CopyPaths => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
app.state.toasts.push(
|
||||
ToastKind::Info,
|
||||
app.lang()
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
iced::clipboard::write(export::paths_text(&card.report))
|
||||
}
|
||||
DialogMessage::CopyPath(index) => {
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some((_, path)) = card.report.path_lines().into_iter().nth(index) else {
|
||||
return Task::none();
|
||||
};
|
||||
app.state.toasts.push(
|
||||
ToastKind::Info,
|
||||
app.lang()
|
||||
.catalog()
|
||||
.text(unlucky_i18n::catalog::Key::CrashDialogCopied),
|
||||
);
|
||||
iced::clipboard::write(path.display().to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Esc` 关弹窗(有弹窗时**先吃这一条**,不让它落到 AI 栏的 Esc 收起上)。
|
||||
pub(super) fn escape(app: &mut App) -> bool {
|
||||
if app.state.dialogs.current().is_none() {
|
||||
return false;
|
||||
}
|
||||
app.close_dialog();
|
||||
app.refresh_view();
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 启动失败弹窗的**取证夹具**(`ULL_DIALOG_FIXTURE`,只在 `--screenshot` 下生效)。
|
||||
//!
|
||||
//! ## 为什么要有它
|
||||
//!
|
||||
//! 弹窗要出两张图(「有 crash-report」/「只有捕获输出」),而真机上要复现一次崩溃得先让一个
|
||||
//! 真实的 MC 进程死掉——截图通道要的是**确定性**。做法与 `ULL_ACCOUNT_STATE` 同一档:
|
||||
//! 在临时目录里造一份**真的文件树**(launcher.log / 会话 JSONL / crash-reports / latest.log),
|
||||
//! 于是弹窗上的路径是真路径、点「导出诊断包」真的会打出一个装得下这些文件的 zip。
|
||||
//!
|
||||
//! 取值:
|
||||
//!
|
||||
//! | 值 | 现场 |
|
||||
//! |---|---|
|
||||
//! | `crash-report` | 游戏写了 `crash-reports/crash-*.txt`(证据一) |
|
||||
//! | `no-logs` | 没有 crash-report、没有 latest.log,只有会话 JSONL 里的 stderr(证据二) |
|
||||
//! | `export` | 同 `no-logs`,并在出图前**真的跑一次导出**(第三张图:路径 + 字节数) |
|
||||
//!
|
||||
//! 夹具只影响这一次进程;产品路径(`host/launch.rs` 收尾 → `crash_report::publish` →
|
||||
//! `refresh_view` → 弹窗)与它完全同一条,这里只是**代填事实**。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::launch::crash_report::{self, ArtifactFact, CrashReport};
|
||||
use unlucky_core::launch::crash_scan::{self, CrashSource};
|
||||
use unlucky_presenter::dto::{LaunchFinished, LogOutcome};
|
||||
|
||||
use crate::app::state::App;
|
||||
|
||||
/// 现场根(`%TEMP%/ull-m187-fixture-<pid>`)。
|
||||
fn root() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("ull-m187-fixture-{}", std::process::id()))
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(path, text);
|
||||
}
|
||||
|
||||
/// 出图用的夹具(`None` = 没有开关,什么都不做)。
|
||||
pub(super) fn apply(app: &mut App) -> Option<String> {
|
||||
let mode = std::env::var("ULL_DIALOG_FIXTURE").ok()?;
|
||||
let mode = mode.trim().to_lowercase();
|
||||
if !app.has_screenshot() {
|
||||
return None;
|
||||
}
|
||||
let root = root();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let logs = root.join("logs");
|
||||
write(
|
||||
&logs.join("launcher.log"),
|
||||
"2026-11-08T15:30:11.002Z [boot] renderer=wgpu\n\
|
||||
2026-11-08T15:30:12.441Z [launch] instance=survival spawn game_dir=… java=javaw (21)\n\
|
||||
2026-11-08T15:30:19.870Z [launch] instance=survival session=9f2c exit=1\n",
|
||||
);
|
||||
write(&logs.join("install.log"), "installed 1.20.4-fabric\n");
|
||||
|
||||
let session = "9f2c4a1e-77bd-4f27-9d21-5b0a1f2c3d4e";
|
||||
let session_log = root
|
||||
.join("sessions")
|
||||
.join("game")
|
||||
.join(format!("{session}.jsonl"));
|
||||
write(
|
||||
&session_log,
|
||||
"{\"kind\":\"stdout\",\"payload\":{\"line\":\"[main/INFO]: Loading Minecraft 1.20.4\"}}\n\
|
||||
{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.RuntimeException: mixin injection failed\"}}\n\
|
||||
{\"kind\":\"stderr\",\"payload\":{\"line\":\"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW\"}}\n\
|
||||
{\"kind\":\"exit\",\"payload\":{\"code\":1}}\n",
|
||||
);
|
||||
let tail: Vec<String> = vec![
|
||||
"[main/INFO]: Loading Minecraft 1.20.4".to_string(),
|
||||
"java.lang.RuntimeException: mixin injection failed".to_string(),
|
||||
"Caused by: java.lang.NoClassDefFoundError: org/lwjgl/glfw/GLFW".to_string(),
|
||||
];
|
||||
|
||||
let run_dir = root.join("instances").join("survival");
|
||||
let latest = run_dir.join("logs").join("latest.log");
|
||||
let version_json = root
|
||||
.join("game")
|
||||
.join("versions")
|
||||
.join("1.20.4-fabric")
|
||||
.join("1.20.4-fabric.json");
|
||||
write(&version_json, "{\"id\":\"1.20.4-fabric\"}\n");
|
||||
|
||||
let mut artifacts: Vec<ArtifactFact> = Vec::new();
|
||||
if mode != "no-logs" {
|
||||
write(
|
||||
&run_dir
|
||||
.join("crash-reports")
|
||||
.join("crash-2026-11-08_15.30.19-client.txt"),
|
||||
"---- Minecraft Crash Report ----\n\
|
||||
Time: 2026-11-08 15:30:19\n\
|
||||
Description: Initializing game\n\n\
|
||||
java.lang.RuntimeException: mixin injection failed\n\
|
||||
\tat net.fabricmc.loader.impl.launch.knot.Knot.init(Knot.java:142)\n\
|
||||
\tat net.minecraft.client.main.Main.main(Main.java:51)\n",
|
||||
);
|
||||
write(&latest, "[15:30:12] [main/INFO]: Loading Minecraft 1.20.4\n[15:30:19] [Render thread/ERROR]: GLFW init failed\n");
|
||||
let since = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
|
||||
artifacts = crash_scan::scan(&run_dir, since, 8)
|
||||
.into_iter()
|
||||
.map(|artifact| ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path,
|
||||
excerpt: artifact.excerpt,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let report = CrashReport {
|
||||
instance: "survival".to_string(),
|
||||
name: "生存服的 1.20.4".to_string(),
|
||||
version: "1.20.4-fabric".to_string(),
|
||||
session: session.to_string(),
|
||||
session_log: Some(session_log),
|
||||
version_json: Some(version_json),
|
||||
launcher_logs: Some(logs),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".to_string(),
|
||||
uptime_ms: 7_400,
|
||||
command: "javaw -Xmx4096m -Djava.library.path=<home>… ".to_string(),
|
||||
java: "C:/Program Files/Java/jdk-21/bin/javaw.exe (major 21)".to_string(),
|
||||
system: "os=windows version=10.0.26100 arch=x86_64".to_string(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
};
|
||||
crash_report::publish(report);
|
||||
let finished = LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: session.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 7_400,
|
||||
failure: None,
|
||||
};
|
||||
app.open_failure_dialog(&finished);
|
||||
if mode == "export" {
|
||||
// 走**真的**那一条:与用户点「导出诊断包」同一个处理函数。
|
||||
let _ = super::dialog::on_dialog(app, crate::app::dialog::Message::Primary);
|
||||
}
|
||||
Some(mode)
|
||||
}
|
||||
|
||||
/// 夹具里崩溃报告的来源名(与 `CrashSource::CrashReport` 同源,测试用)。
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn crash_report_source() -> &'static str {
|
||||
CrashSource::CrashReport.as_str()
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! 下载页在 app 层的那几支(M94;M105 补上安装选项)。
|
||||
//!
|
||||
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
|
||||
//! 几个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
|
||||
//! mission scope)。
|
||||
//!
|
||||
//! ## 各支各自的纪律(M105 后的口径)
|
||||
//!
|
||||
//! | 消息 | 落点 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`pages::Message::SelectVersion`] | 只改**草稿** | 选中是页面内状态,不是业务命令;顺手清掉加载器版本(换了原版版本,旧版本号几乎必然不适用) |
|
||||
//! | [`pages::Message::SetLoader`] | 改草稿 + 必要时派 `ListLoaderVersions` | 加载器版本列表来自官方元数据,页面不能自己编 |
|
||||
//! | [`pages::Message::SetLoaderVersion`] / `SetInstanceName` | 只改草稿 | 纯输入 |
|
||||
//! | [`pages::Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)` | **确认才开工**(带加载器 / 加载器版本 / 实例名) |
|
||||
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 见 `host/dispatch.rs`:真停 + 作废旧代次 |
|
||||
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
|
||||
//!
|
||||
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
|
||||
//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。
|
||||
//!
|
||||
//! ## 分类为什么既写视图模型、又派指令
|
||||
//!
|
||||
//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在
|
||||
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
|
||||
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
|
||||
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
|
||||
//!
|
||||
//! ## 草稿为什么住在这里而不是页面模型里
|
||||
//!
|
||||
//! `refresh_view()` 每次事件都把 `self.view` 整棵重建(连安装进度都算),页面存不住「用户正在填、
|
||||
//! 还没确认」的东西。于是草稿放 `App::download_draft`,与分类筛选同一个做法:
|
||||
//! 改草稿 → `view_dirty = true` → `refresh_view()`。
|
||||
|
||||
use unlucky_presenter::dto::CreateRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。
|
||||
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
|
||||
match message {
|
||||
pages::Message::SelectVersion(id) => {
|
||||
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
|
||||
// 换了原版版本:加载器版本是**按 (加载器, 原版版本)** 取的(见
|
||||
// `CoreEvent::LoaderVersions` 的缓存键),旧的版本号留着只会误导。
|
||||
self.download_draft.loader_version.clear();
|
||||
}
|
||||
self.download_draft.selected = Some(id.clone());
|
||||
self.mark_download_dirty();
|
||||
// 选了加载器版本之后换原版版本,列表要跟着换——已经查过的直接从快照读,不重复派。
|
||||
self.ensure_loader_versions();
|
||||
}
|
||||
pages::Message::SetLoader(loader) => {
|
||||
self.download_draft.loader = *loader;
|
||||
self.download_draft.loader_version.clear();
|
||||
self.mark_download_dirty();
|
||||
self.ensure_loader_versions();
|
||||
}
|
||||
pages::Message::SetLoaderVersion(version) => {
|
||||
self.download_draft.loader_version = version.clone();
|
||||
self.mark_download_dirty();
|
||||
}
|
||||
pages::Message::SetInstanceName(name) => {
|
||||
self.download_draft.instance_name = name.clone();
|
||||
self.mark_download_dirty();
|
||||
}
|
||||
pages::Message::InstallVersion(id) => {
|
||||
// 防守:按钮带的是**页面上那一行**的 id,草稿里的选中项理论上与它一致;
|
||||
// 万一不一致(例如列表刚换过、用户手快),不能让 A 版本的加载器版本落到 B 版本上。
|
||||
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
|
||||
self.download_draft.selected = Some(id.clone());
|
||||
self.download_draft.loader_version.clear();
|
||||
}
|
||||
let request = self.install_request(id);
|
||||
self.dispatch(AppCommand::CreateInstance(request));
|
||||
}
|
||||
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
|
||||
pages::Message::SetCategory(category) => {
|
||||
self.view.download.category = *category;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
if let Some(kind) = category.command() {
|
||||
self.dispatch(AppCommand::ListVersions(kind));
|
||||
}
|
||||
}
|
||||
// M107:查询词落在快照里(无状态视图的唯一存活方式),再走同一条重建路径过滤。
|
||||
pages::Message::SetVersionQuery(query) => {
|
||||
self.rebuild_after_query(query.clone());
|
||||
}
|
||||
pages::Message::ClearVersionQuery => {
|
||||
self.rebuild_after_query(String::new());
|
||||
}
|
||||
// M107:主版本桶的选中是纯视图态,不重建、不派命令(同 `SelectVersion`)。
|
||||
//
|
||||
// **搜索期间点桶 = 「我就是要看这个桶」**(复审 r2 P2-1(b)):搜索让第二级失效(P2-1 的
|
||||
// 一致性放宽),如果这里只改选择而不清查询,用户会看到「点了没反应、清空搜索后才生效」
|
||||
// 的活控件。所以搜索中点桶顺带退出搜索,按新桶过滤——一次点击做完用户想要的两件事。
|
||||
pages::Message::SelectMajor(key) => {
|
||||
if self.view.download.has_query() {
|
||||
// 先退出搜索再落选择:`rebuild_after_query` 会用**全量行**重算桶并回填选择
|
||||
// (它带回 `selected_major`,且校验桶仍存在)。
|
||||
self.view.download.selected_major = key.clone();
|
||||
self.rebuild_after_query(String::new());
|
||||
} else {
|
||||
self.view.download.selected_major = key.clone();
|
||||
}
|
||||
}
|
||||
// M107:类型栏的手动折叠也是纯视图态。
|
||||
pages::Message::ToggleTypeRail => {
|
||||
self.view.download.type_rail_folded = !self.view.download.type_rail_folded;
|
||||
}
|
||||
pages::Message::Action(pages::Action::RefreshSources) => {
|
||||
match self.view.download.category.command() {
|
||||
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
|
||||
None => self.dispatch(AppCommand::RefreshAll),
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 改草稿之后统一走这一条:置脏 + 立刻重建(不等下一拍,点了就有反应)。
|
||||
fn mark_download_dirty(&mut self) {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
|
||||
/// 确认安装时的 `CreateRequest`(草稿 → 指令载荷)。
|
||||
///
|
||||
/// 实例名留空就让 core 用版本号(`CreateRequest::instance_name` 的既有口径),
|
||||
/// 所以这里**不**自己拼一个默认名——两处各拼一份迟早会漂。
|
||||
fn install_request(&self, id: &str) -> CreateRequest {
|
||||
let draft = &self.download_draft;
|
||||
CreateRequest {
|
||||
name: draft.instance_name.trim().to_string(),
|
||||
version: id.to_string(),
|
||||
loader: draft.loader.kind(),
|
||||
loader_version: draft.loader_version.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前草稿需要加载器版本列表、而快照里还没有时,派一次查询。
|
||||
///
|
||||
/// 判据是 `state.loader_versions_for(...)` 的 `None`(= 没查过):查过的(成功或失败)都不再重复派,
|
||||
/// 失败要重试走 `RefreshSources` 那条路,页面上的红色原因行不会因为一次点击就消失。
|
||||
fn ensure_loader_versions(&mut self) {
|
||||
let loader = self.download_draft.loader;
|
||||
if loader == pages::Loader::Vanilla {
|
||||
return;
|
||||
}
|
||||
let Some(version) = self.download_draft.selected.clone() else {
|
||||
return;
|
||||
};
|
||||
// 静态就不支持的组合不联网(否则只会换来一条「取不到」的假原因;真正的原因是
|
||||
// 「这个加载器没有这个版本的构建」,页面已经用静态规则说清了)。
|
||||
let kind = self
|
||||
.state
|
||||
.versions
|
||||
.iter()
|
||||
.find(|summary| summary.id == version)
|
||||
.map(|summary| summary.kind);
|
||||
if let Some(kind) = kind {
|
||||
if loader.supports(&version, kind).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.loader_versions_for(loader.kind(), &version)
|
||||
.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.dispatch(AppCommand::ListLoaderVersions {
|
||||
kind: loader.kind(),
|
||||
mc_version: version,
|
||||
});
|
||||
}
|
||||
|
||||
/// 搜索词变了:写进快照 → 重建视图 → **把纯视图态捡回来**。
|
||||
///
|
||||
/// `refresh_view()` 整块换掉 `self.view`,而它只把 `category` 与**安装草稿**带回去(`app/state.rs`
|
||||
/// 归 M105/M106)。所以「折叠状态」与「选中的主版本桶」必须在重建前后自己搬一次——否则用户敲第一个
|
||||
/// 字符,展开着的类型栏就自己收起来、选中的主版本也跳回「全部」。
|
||||
/// 「搜索时侧栏保留当前位置」这条要求,落点就是这里的四行。
|
||||
fn rebuild_after_query(&mut self, query: String) {
|
||||
self.state.download_query = query;
|
||||
let major = self.view.download.selected_major.take();
|
||||
let folded = self.view.download.type_rail_folded;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
// 回填要**校验**:桶是按当前行聚合法出来的,新桶列表里没有的那个选择不该被复活
|
||||
// (复审 P2-1(a):否则会出现「侧栏没有任何一项点亮」的幽灵选中)。
|
||||
self.view.download.selected_major = major.filter(|key| {
|
||||
self.view
|
||||
.download
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.key.as_deref() == Some(key.as_str()))
|
||||
});
|
||||
self.view.download.type_rail_folded = folded;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! 搜索链路的**端到端**断言(复审 P2-5)。
|
||||
//!
|
||||
//! 为什么非要在 app 层写:这条链是 `Message → on_page → on_download → AppState.download_query
|
||||
//! → refresh_view → from_snapshot 过滤`,中间任何一环断掉,页面层测试都看不见(页面只拿到
|
||||
//! 已经过滤好的模型)。复审原话:「把 `rebuild_after_query` 第一行删掉,现有测试全绿,而搜索
|
||||
//! 变成假控件」——这几条就是补那个洞的。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_presenter::dto::{AppEvent, Backend, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 只记账、不回事件的后端(同 `app/update/download_wiring.rs`):命令出了 UI 层,状态一个字节
|
||||
/// 都不许变——否则「本地乐观回显」与「事件回流」终态相同,断言会假绿。
|
||||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.0.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
fn version(id: &str) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个装好三行版本的 App(`1.21.9` / `1.20.4` / `1.20.1`)。
|
||||
fn app() -> App {
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
|
||||
version("1.21.9"),
|
||||
version("1.20.4"),
|
||||
version("1.20.1"),
|
||||
])));
|
||||
app.refresh_view();
|
||||
app
|
||||
}
|
||||
|
||||
/// 键入 → 快照 → 列表:一条走完的链。
|
||||
///
|
||||
/// 抓得住:`rebuild_after_query` 不再写 `AppState.download_query`(搜索变假控件)、
|
||||
/// 或 `from_snapshot` 不再按 query 过滤(列表不跟着收窄)。
|
||||
/// 抓不住:真机上 `text_input` 的按键是否走到这里(那是组件的事,`search_bar` 已接 `on_input`)。
|
||||
#[test]
|
||||
fn typing_a_query_reaches_the_state_and_narrows_the_list() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.view.download.rows.len(), 3, "初始是全量");
|
||||
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
|
||||
assert_eq!(app.state.download_query, "1.20", "查询词落在快照里");
|
||||
assert_eq!(app.view.download.rows.len(), 2, "列表收窄到两行");
|
||||
assert_eq!(app.view.download.hits, 2);
|
||||
assert_eq!(app.view.download.query, "1.20", "模型回显查询词");
|
||||
|
||||
// 清空回到全量(`ClearVersionQuery` 那一支)
|
||||
let _ = app.on_page(pages::Message::ClearVersionQuery);
|
||||
assert!(app.state.download_query.is_empty());
|
||||
assert_eq!(app.view.download.rows.len(), 3);
|
||||
}
|
||||
|
||||
/// 查询词**跨事件存活**:再来一次重建,列表仍是收窄的。
|
||||
///
|
||||
/// 抓得住:把查询词只写在视图模型里(下一次 `refresh_view` 就丢)——这是「搜索看起来能用、
|
||||
/// 一来事件就复位」的坏法。
|
||||
#[test]
|
||||
fn the_query_survives_a_snapshot_rebuild() {
|
||||
let mut app = app();
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.21".to_string()));
|
||||
assert_eq!(app.view.download.rows.len(), 1);
|
||||
|
||||
// 安装进度这类事件回来时,`pump()` 会重建视图:查询词必须还在
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::InstallProgress(None)));
|
||||
app.refresh_view();
|
||||
|
||||
assert_eq!(app.state.download_query, "1.21", "查询词在快照里,重建不丢");
|
||||
assert_eq!(app.view.download.rows.len(), 1, "列表仍收窄");
|
||||
}
|
||||
|
||||
/// **搜索期间点主版本桶 = 退出搜索并按该桶过滤**(复审 r2 P2-1(b))。
|
||||
///
|
||||
/// 抓得住:`SelectMajor` 只改选择不清查询 —— 那时搜索让第二级失效(P2-1 的一致性放宽),
|
||||
/// 用户点了当场没反应、要等清空搜索那次点击才生效,就是「可点但无反应」的活控件。
|
||||
/// 抓不住:真机点击是否走到这里(`ListItem`/`Chip` 的 `on_press` 是组件既有实现)。
|
||||
#[test]
|
||||
fn clicking_a_bucket_during_a_search_leaves_the_search() {
|
||||
let mut app = app();
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
assert_eq!(app.view.download.rows.len(), 2, "此刻在搜 1.20");
|
||||
|
||||
// 点「1.21」这个桶
|
||||
let _ = app.on_page(pages::Message::SelectMajor(Some("1.21".to_string())));
|
||||
|
||||
assert!(app.state.download_query.is_empty(), "点桶顺带退出搜索");
|
||||
assert!(!app.view.download.has_query());
|
||||
assert_eq!(
|
||||
app.view.download.selected_major.as_deref(),
|
||||
Some("1.21"),
|
||||
"选择落在刚点的那个桶上"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view.download.rows.len(),
|
||||
3,
|
||||
"查询词清掉之后模型回到全量(桶过滤发生在渲染时的 visible 里)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view
|
||||
.download
|
||||
.buckets
|
||||
.iter()
|
||||
.find(|bucket| bucket.key.as_deref() == Some("1.21"))
|
||||
.map(|bucket| bucket.count),
|
||||
Some(1),
|
||||
"1.21 桶里只有一个版本 ⇒ 列表会显示一行"
|
||||
);
|
||||
|
||||
// 没有搜索词时点桶照旧只改选择(不触发多余重建)
|
||||
let _ = app.on_page(pages::Message::SelectMajor(None));
|
||||
assert_eq!(app.view.download.selected_major, None);
|
||||
}
|
||||
|
||||
/// 搜索时侧栏的**位置与折叠**不许丢(`rebuild_after_query` 的三行)。
|
||||
///
|
||||
/// 抓得住:删掉那两行回填(敲第一个字符,类型栏自己弹开、主版本跳回「全部」)。
|
||||
/// 抓不住:其它事件(安装进度)回来时也会复位 —— 那条要 `app/state.rs` 的 `refresh_view`
|
||||
/// 一起改,不在本 mission scope,已在报告里明确登记「留到 M105 之后」。
|
||||
#[test]
|
||||
fn a_query_keeps_the_rail_position_and_the_fold() {
|
||||
let mut app = app();
|
||||
app.view.download.selected_major = Some("1.20".to_string());
|
||||
app.view.download.type_rail_folded = true;
|
||||
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
app.view.download.selected_major.as_deref(),
|
||||
Some("1.20"),
|
||||
"桶选择还在(生效值在搜索期间放宽,但选择本身不许被清)"
|
||||
);
|
||||
assert!(app.view.download.type_rail_folded, "折叠状态还在");
|
||||
|
||||
// 选中一个**新桶列表里不存在**的桶:搜索后不许把它复活成幽灵选中(P2-1(a))
|
||||
app.view.download.selected_major = Some("9.99".to_string());
|
||||
let _ = app.on_page(pages::Message::SetVersionQuery("1.20".to_string()));
|
||||
assert_eq!(
|
||||
app.view.download.selected_major, None,
|
||||
"新桶列表里没有 9.99 ⇒ 回填空值,而不是留一个点不亮的幽灵选中"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。
|
||||
//!
|
||||
//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见
|
||||
//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。
|
||||
/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。
|
||||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.0.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。
|
||||
/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。
|
||||
#[test]
|
||||
fn download_intents_dispatch_real_commands() {
|
||||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&log))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// M95 起 `on_page` 返回 `Task`(剪贴板命令要走返回的 Task):这里只关心派发与视图模型,
|
||||
// 任务本身没有需要断言的后效,显式丢弃。
|
||||
let _ = app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
|
||||
let _ = app.on_page(pages::Message::CancelInstall);
|
||||
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
|
||||
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
|
||||
// M105:「安装」不再是「立刻装原版」,而是把**草稿里的选项**一起交出去
|
||||
// (`CreateInstance` 带加载器 / 加载器版本 / 实例名)——这正是用户报的「没有加载器可选」的修复点。
|
||||
let expected = vec![
|
||||
AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest {
|
||||
version: "1.21.9".to_string(),
|
||||
loader: unlucky_presenter::dto::LoaderKind::Vanilla,
|
||||
loader_version: String::new(),
|
||||
name: String::new(),
|
||||
}),
|
||||
AppCommand::CancelInstall,
|
||||
AppCommand::ListVersions(VersionCategory::Release),
|
||||
];
|
||||
assert_eq!(
|
||||
*log.borrow(),
|
||||
expected,
|
||||
"三条各派一条;`SelectVersion` 不派"
|
||||
);
|
||||
// M105:选中项住在**草稿**里(`refresh_view` 每次事件都会整棵重建页面模型,
|
||||
// 页面自己存不住它);recorder 不回 `VersionListChanged` ⇒ 列表是空的 ⇒
|
||||
// 视图模型里那一行不存在,于是 `selected` 被过滤成 `None`(防「切分类后还留着一个点不动的安装」)。
|
||||
assert_eq!(app.download_draft.selected.as_deref(), Some("1.21.8"));
|
||||
assert_eq!(
|
||||
app.view.download.selected, None,
|
||||
"选中的行不在列表里时,视图模型不该拿着它"
|
||||
);
|
||||
assert_eq!(app.view.download.category, pages::Category::Release);
|
||||
assert_eq!(
|
||||
app.view.download.rows.len(),
|
||||
0,
|
||||
"recorder 不回事件 ⇒ 列表不该冒出来"
|
||||
);
|
||||
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
|
||||
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
|
||||
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.view.download.category,
|
||||
pages::Category::Release,
|
||||
"重建视图模型之后分类必须还在"
|
||||
);
|
||||
}
|
||||
|
||||
/// **取消之后面板要回到「未安装态」且能立刻再装**(M105 的验收项,CI 可跑的那一半)。
|
||||
///
|
||||
/// 现场(用户原话):「取消下载按钮点了没反应」。真根因是取消生效了又被旧任务的事件盖回去
|
||||
/// (presenter 侧的代次守门在 `host/cancel_tests.rs`);这一条钉的是**页面这一侧**:
|
||||
/// 收到 `InstallProgress(None)` 之后,面板必须回到安装选项区、确认键可用、选中的版本还在
|
||||
/// ——不需要用户重新点一遍版本。
|
||||
///
|
||||
/// 抓得住:取消后仍画着进度面板 / 选项区丢了选中 / 确认键还是禁用。
|
||||
/// 抓不住:真网络下的取消延迟(那条是 `#[ignore]` 的真网络用例)。
|
||||
#[test]
|
||||
fn cancelling_gives_the_panel_back_to_the_install_options() {
|
||||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&log))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::VersionListChanged(vec![
|
||||
unlucky_presenter::dto::VersionSummary {
|
||||
id: "1.21.9".to_string(),
|
||||
kind: unlucky_presenter::dto::VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
},
|
||||
]),
|
||||
));
|
||||
app.refresh_view();
|
||||
|
||||
// 选中 → 面板给出选项区
|
||||
let _ = app.on_page(pages::Message::SelectVersion("1.21.9".to_string()));
|
||||
let options = app
|
||||
.view
|
||||
.download
|
||||
.options
|
||||
.as_ref()
|
||||
.expect("选中版本之后要出现安装选项区");
|
||||
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
|
||||
assert_eq!(options.version, "1.21.9");
|
||||
|
||||
// 开装:host 回一条「下载中」→ 面板换成进度
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstallProgress(Some(
|
||||
unlucky_presenter::dto::InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: unlucky_presenter::dto::InstallStage::Downloading,
|
||||
files_done: 9,
|
||||
files_total: 512,
|
||||
..unlucky_presenter::dto::InstallProgressView::new()
|
||||
},
|
||||
)),
|
||||
));
|
||||
app.refresh_view();
|
||||
assert!(app.view.download.install.is_some(), "装的时候面板讲进度");
|
||||
assert!(app.view.download.options.is_none(), "进度与选项互斥");
|
||||
|
||||
// 取消:host 回 `InstallProgress(None)`(`CancelInstall` 的既有语义)
|
||||
let _ = app.on_page(pages::Message::CancelInstall);
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstallProgress(None),
|
||||
));
|
||||
app.refresh_view();
|
||||
assert!(app.view.download.install.is_none(), "取消后面板必须收回");
|
||||
let options = app
|
||||
.view
|
||||
.download
|
||||
.options
|
||||
.as_ref()
|
||||
.expect("取消后要回到安装选项区(未安装态),而不是只剩一句提示");
|
||||
assert_eq!(
|
||||
options.version, "1.21.9",
|
||||
"选中项要还在,用户不必重新点一遍"
|
||||
);
|
||||
assert!(options.can_confirm, "取消后要能立刻再装一次");
|
||||
}
|
||||
|
||||
/// **取消提示只说一次,且说的是页面语言**(复审 P2-2)。
|
||||
///
|
||||
/// 修前:`presenter` 在取消时直接推一条**写死的中文** Toast,而下载页此刻渲染英文 ⇒
|
||||
/// 选项区英文、取消弹窗中文,同屏两语。现在 host 只发信号(`CoreEvent::InstallCancelled`),
|
||||
/// 文案由页面层按 `Key::DownloadCancelled` 现求,语言跟页面走。
|
||||
///
|
||||
/// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、
|
||||
/// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。
|
||||
/// 抓不住:`Lang` 本身接得对不对(M106 起语言来自 `App::lang()`,本测试**显式钉**成英文,
|
||||
/// 否则这条断言会跟着跑测试那台机器的 locale 变——那是假绿)。
|
||||
#[test]
|
||||
fn cancelling_announces_once_in_the_page_language() {
|
||||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&log))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast");
|
||||
|
||||
// M106:语言显式钉成英文。不钉的话「页面语言」就是跑测试那台机器的 locale(英文 CI 绿、
|
||||
// 中文机器红)——那种测试证明不了任何事。钉住之后期望值仍从目录取,守的是同一条原意:
|
||||
// **提示语来自 i18n 目录,不是 presenter 写死的中文**。
|
||||
app.set_language(unlucky_i18n::LangChoice::Fixed(unlucky_i18n::Lang::En));
|
||||
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstallCancelled,
|
||||
));
|
||||
app.refresh_view();
|
||||
let items = app.state.toasts.items();
|
||||
assert_eq!(items.len(), 1, "取消要提示一条(且只一条)");
|
||||
let text = items[0].text.clone();
|
||||
assert_eq!(
|
||||
text,
|
||||
pages::cancelled_toast(unlucky_i18n::Lang::En),
|
||||
"提示语必须来自 i18n 目录(不是 presenter 写死的中文)"
|
||||
);
|
||||
assert!(
|
||||
!text.contains('取') && !text.contains('消'),
|
||||
"页面此刻是英文,取消提示不该混中文:{text}"
|
||||
);
|
||||
|
||||
// 再重建一次视图:不该重复弹(信号是计数,不是「每拍都算一次」)
|
||||
app.refresh_view();
|
||||
assert_eq!(app.state.toasts.items().len(), 1, "同一次取消只提示一次");
|
||||
let _ = app.state.toasts.len();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 实例页「启动参数」区的消息落点(M157)。
|
||||
//!
|
||||
//! 三件事:
|
||||
//! 1. **编辑**只改页面状态(`App::launch_form`)并置脏——不 dispatch,不打网络;
|
||||
//! 2. **保存**派一条 [`AppCommand::SetInstanceLaunchArgs`](整份五项补丁),回程由
|
||||
//! `InstancesChanged` 快照刷新页面(不做乐观回显,与 M90 的启动/停止同一条纪律);
|
||||
//! 3. **恢复跟随全局**只清表单(仍需用户点保存)——「点一下就写盘」会让误触直接生效。
|
||||
//!
|
||||
//! **清脏的唯一时机是「回程快照等于表单」**:`LaunchForm::sync` 在「同实例 + 脏」时只在
|
||||
//! `matches(&view)` 成立时才清脏并重播种,其余情况原样保留草稿。保存本身**不清脏**——落盘成败
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 启动参数区的一条消息。
|
||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
||||
let Some(instance) = self.selected_instance_id() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.launch_form.bound_to(&instance) {
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match message {
|
||||
InstanceMessage::Jvm(text) => {
|
||||
self.launch_form.jvm = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Game(text) => {
|
||||
self.launch_form.game = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Memory(text) => {
|
||||
self.launch_form.memory = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Java(text) => {
|
||||
self.launch_form.java = text;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Backend(id) => {
|
||||
self.launch_form.backend = id;
|
||||
self.launch_form.dirty = true;
|
||||
}
|
||||
InstanceMessage::Reset => {
|
||||
self.launch_form.reset();
|
||||
}
|
||||
InstanceMessage::Save => {
|
||||
// **不在这里清脏**:`dispatch` 只是把命令送出去,落盘成败要等回程事件。
|
||||
// 失败时(内存 `4g` / 写盘失败)pump 会带**旧快照**重建视图,脏标记若已清掉,
|
||||
// `LaunchForm::sync` 就会用旧值覆盖用户刚敲的草稿(M157 r3 复审 P2-2)。
|
||||
// 清脏的唯一时机是「回程快照已经等于表单」——见 `LaunchForm::sync`。
|
||||
let patch: LaunchArgsPatch = self.launch_form.patch();
|
||||
self.dispatch(AppCommand::set_launch_args(&instance, patch));
|
||||
}
|
||||
}
|
||||
// 纯本地编辑:不重建视图模型(页面直接从表单画),只要一帧重绘即可——iced 在
|
||||
// 消息处理完之后本来就会画一帧,所以这里什么都不排。
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
|
||||
fn selected_instance_id(&self) -> Option<String> {
|
||||
match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::LaunchArgsView;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages::{InstanceMessage, LaunchForm};
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn app_with_form() -> App {
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(unlucky_presenter::mock::MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// 进详情页 + 直接放一份「已播种」的表单:本测试只验消息落点,
|
||||
// 不验播种(那在 `LaunchForm::sync` 的单测里)。
|
||||
app.state.navigate(unlucky_presenter::router::Route::Detail(
|
||||
INSTANCE.to_string(),
|
||||
));
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some(INSTANCE),
|
||||
&LaunchArgsView {
|
||||
instance_id: INSTANCE.to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// 测试用的实例 id(mock 后端的实例快照里有它)。
|
||||
const INSTANCE: &str = "survival";
|
||||
|
||||
/// 编辑 → 脏;恢复 → 五项清空仍需保存;保存前不发命令。
|
||||
#[test]
|
||||
fn editing_marks_dirty_and_reset_waits_for_save() {
|
||||
let mut app = app_with_form();
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("6144".to_string()));
|
||||
assert_eq!(app.launch_form.memory, "6144");
|
||||
assert!(app.launch_form.dirty);
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::Backend("vulkan".to_string()));
|
||||
assert_eq!(app.launch_form.backend, "vulkan");
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::Reset);
|
||||
assert_eq!(app.launch_form.memory, "");
|
||||
assert_eq!(app.launch_form.backend, "default");
|
||||
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
|
||||
}
|
||||
|
||||
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
|
||||
#[test]
|
||||
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
|
||||
let mut app = app_with_form();
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some("other"),
|
||||
&LaunchArgsView {
|
||||
instance_id: "other".to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("9999".to_string()));
|
||||
assert_eq!(app.launch_form.memory, "");
|
||||
assert!(!app.launch_form.dirty);
|
||||
}
|
||||
|
||||
/// **保存只派发、不清脏**(M157 r3 复审 P2-2):落盘成败要等回程事件,
|
||||
/// 派发前清掉脏标记会让失败后的重播覆盖用户草稿。
|
||||
///
|
||||
/// 抓得住:Save 分支里又写回 `dirty = false`(那条路径在失败时丢草稿)。
|
||||
/// 抓不住:回程事件的真实时序——清脏判据在 `LaunchForm::sync` 的
|
||||
/// `confirmed_save_clears_dirty_and_reseeds` / `failed_save_keeps_the_draft` 里。
|
||||
#[test]
|
||||
fn save_dispatch_keeps_the_dirty_flag() {
|
||||
let mut app = app_with_form();
|
||||
let _ = app.on_instance_message(InstanceMessage::Memory("4g".to_string()));
|
||||
assert!(app.launch_form.dirty);
|
||||
let _ = app.on_instance_message(InstanceMessage::Save);
|
||||
assert!(
|
||||
app.launch_form.dirty,
|
||||
"派发后仍要保留脏标记,等回程快照确认"
|
||||
);
|
||||
assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 动效的**起跑点**(M192 r1 复审 P1-1 修的正是这里)。
|
||||
//!
|
||||
//! ## 为什么这条不能在 `view` 里
|
||||
//!
|
||||
//! iced 0.14 的处理顺序是:
|
||||
//!
|
||||
//! ```text
|
||||
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
|
||||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||
//! ```
|
||||
//!
|
||||
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
||||
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
||||
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
||||
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
||||
//! **结构上**不可能暴露这条。
|
||||
//!
|
||||
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
||||
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
||||
//!
|
||||
//! ## 标识从哪来
|
||||
//!
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
||||
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
||||
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::pages;
|
||||
|
||||
use super::boot_env::MotionFixture;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
|
||||
pub(in crate::app) fn sync_motion(&mut self) {
|
||||
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
|
||||
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
|
||||
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
||||
// 截图通道例外:它的窗口句柄也是异步到的,而序列图必须从第 1 帧就按钉住的相位画。
|
||||
if self.window.is_none() && !self.has_screenshot() {
|
||||
return;
|
||||
}
|
||||
let route = self.state.route();
|
||||
let screen = screen_key(route);
|
||||
let detail = match route {
|
||||
Route::Detail(id) => Some(id.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
motion::observe(screen, detail);
|
||||
}
|
||||
|
||||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
|
||||
pub(in crate::app) fn motion_fixture(&mut self) {
|
||||
let Some(fixture) = super::boot_env::env_motion_fixture() else {
|
||||
return;
|
||||
};
|
||||
let route = match (fixture, self.frames) {
|
||||
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
|
||||
(MotionFixture::Detail, 1) => Route::Instances,
|
||||
(MotionFixture::Detail, 2) => {
|
||||
let Some(first) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
Route::Detail(first)
|
||||
}
|
||||
(MotionFixture::Route(route), 1) => route,
|
||||
_ => return,
|
||||
};
|
||||
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
|
||||
/// 真机空闲取证:`ULL_MOTION_FIXTURE_AT=<秒>` 到点就**真的**导航一次(`on_heartbeat` 调)。
|
||||
///
|
||||
/// 为什么不用点击:[`super::boot_env::env_motion_fixture_at`] 的文档写了原因——本机会话没有
|
||||
/// 前台窗口,合成鼠标事件递不进 iced 的输入层。心跳这条路与点击在 `update` 之后是同一段代码。
|
||||
/// 返回 `true` = 这一拍真的跳了页。
|
||||
pub(in crate::app) fn motion_fixture_after_idle(&mut self) -> bool {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
/// 已经过去的心跳数(进程内一次;只有取证开关打开时才会用到)。
|
||||
static FIXTURE_BEATS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
let Some(target) = boot_env::env_motion_fixture_at() else {
|
||||
return false;
|
||||
};
|
||||
let beat = FIXTURE_BEATS.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if beat != target {
|
||||
return false;
|
||||
}
|
||||
let route = match boot_env::env_motion_fixture() {
|
||||
Some(MotionFixture::Route(route)) => route,
|
||||
Some(MotionFixture::Detail) => {
|
||||
let Some(id) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Route::Detail(id)
|
||||
}
|
||||
None => Route::Download,
|
||||
};
|
||||
diag!("[motion] ULL_MOTION_FIXTURE_AT 到点(第 {beat} 次心跳)→ {route:?}");
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由 → 屏的稳定键(`pages::Screen::ALL` 的下标 + 1;`None` = 不归 pages 画)。
|
||||
///
|
||||
/// 与 `pages::Screen::of` 同一判据(`Screen::Instances` ↔ `Route::Instances | Route::Detail(_)`),
|
||||
/// 只是把「有没有这一屏」也表达出来——`pages::Screen::of` 给的是 `Option<Screen>`,这里
|
||||
/// 再折成 `Option<u64>` 给帧泵用。
|
||||
fn screen_key(route: &Route) -> Option<u64> {
|
||||
let screen = pages::Screen::of(route)?;
|
||||
pages::Screen::ALL
|
||||
.iter()
|
||||
.position(|candidate| *candidate == screen)
|
||||
.map(|index| index as u64 + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||||
#[test]
|
||||
fn only_the_five_screens_get_a_key() {
|
||||
let keys: Vec<Option<u64>> = [
|
||||
Route::Instances,
|
||||
Route::Download,
|
||||
Route::Content,
|
||||
Route::Settings,
|
||||
Route::Logs,
|
||||
Route::Detail("survival".to_string()),
|
||||
Route::Accounts,
|
||||
Route::Agent,
|
||||
]
|
||||
.iter()
|
||||
.map(screen_key)
|
||||
.collect();
|
||||
|
||||
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
|
||||
assert_eq!(
|
||||
keys[5],
|
||||
Some(1),
|
||||
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
||||
);
|
||||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 页面消息的落点(`on_page`):解 M94 冲突时从 `update.rs` 拆出来。
|
||||
//!
|
||||
//! 为什么拆:`main` 上 `update.rs` 已经 **397 行**(M94 自己也是因此把下载页四支拆到
|
||||
//! `update/download.rs`),日志页再往里塞「剪贴板 + 清日志」两支就顶破 400 硬线。
|
||||
//! 与 M94 同一种做法:`app/update/**` 属本 mission scope。
|
||||
//!
|
||||
//! ## 穷尽性(M94 复审的 p3,本轮收掉)
|
||||
//!
|
||||
//! 这里是全 crate **唯一**对 [`pages::Message`] 的穷尽匹配点,所以**没有** `_ => {}` 兜底:
|
||||
//! 谁往枚举里加变体却忘了接线,编译器立刻报 E0004。原来是 `_ => {}` + 注释「做穷尽性」——
|
||||
//! 方向正好写反了,它**取消**穷尽性,把「漏接线」变成「点了没反应」。
|
||||
//!
|
||||
//! 代价:由前置分支(`on_download` / 本文件里的剪贴板分支)先消费的变体,在 match 里**仍要
|
||||
//! 显式列出**。那是保住穷尽性的必要代价,也是这条守卫抓得住东西的原因。
|
||||
//!
|
||||
//! ## 边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - 抓得住:**加一个 `pages::Message` 变体却没接线** → 编译失败(E0004),不会静默;
|
||||
//! - 抓不住:加一个 `pages::Action` 变体 —— 它落进 `Action(action)` 的占位分支(打印一行
|
||||
//! `[page] 占位动作 …`),编译期不会报。这是占位期的既有设计,不是本文件能守的。
|
||||
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::pages;
|
||||
use crate::ui::lang::LangState;
|
||||
|
||||
use super::*;
|
||||
|
||||
// --------------------------------------------------------------- 界面语言(M106)
|
||||
//
|
||||
// 这三个方法住在本文件而不是 `app/state.rs`:`state.rs` 贴着 400 行硬线(M104 的 `agent`、
|
||||
// M105 的 `download_draft` 都往那儿长),而 `set_language` 的唯一调用点就是本文件的
|
||||
// `pages::Message::Language` 分支——落点与调用点放一起,读的人不用来回跳。
|
||||
// 与 M93 把 appearance 那批 `App` 方法放进 `app/appearance.rs` 是同一手法。
|
||||
|
||||
impl App {
|
||||
/// 页面消息:导航走路由栈,真业务命令派发,纯视图态改本地呈现。
|
||||
pub(super) fn on_page(&mut self, message: pages::Message) -> Task<Message> {
|
||||
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:`update.rs` 早已贴 400 行
|
||||
if self.on_download(&message) {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match message {
|
||||
pages::Message::Navigate(route) => {
|
||||
// M109:进内容库时补一次检索(首个动作不该是「再点一下搜索」)。
|
||||
// 判据在 `ensure_content_loaded` 里(已有结果/请求在飞就不重复打网络)。
|
||||
let entering_content = route == Route::Content;
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
if entering_content {
|
||||
self.ensure_content_loaded();
|
||||
}
|
||||
}
|
||||
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
|
||||
// M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查)
|
||||
pages::Message::Update(action) => return self.on_update_action(action),
|
||||
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
|
||||
pages::Message::Appearance(change) => self.on_appearance(change),
|
||||
// M101:设置页的四条填页线共用一个投递口;组内变体在 `update/settings.rs` 里穷尽分发
|
||||
// (那一层没有 `_ =>`,见本文件末尾与 `update/settings/tests.rs` 的两条守门)
|
||||
pages::Message::Settings(group) => return self.on_settings(group),
|
||||
// M106:换界面语言(落盘 + 重建视图模型;档位不是业务命令,不派 `AppCommand`)。
|
||||
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
|
||||
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
|
||||
pages::Message::Language(choice) => self.set_language(choice),
|
||||
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
|
||||
pages::Message::Content(message) => return self.on_content(message),
|
||||
// M95:摘要落剪贴板(文本由页面造,这里只搬)
|
||||
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
|
||||
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
|
||||
pages::Message::SelectVersion(_)
|
||||
| pages::Message::InstallVersion(_)
|
||||
| pages::Message::CancelInstall
|
||||
// M105 的三支 + M107 的四支 + 分类:全部由 `on_download` 提前消化(显式列出保住穷尽性)
|
||||
| pages::Message::SetLoader(_)
|
||||
| pages::Message::SetLoaderVersion(_)
|
||||
| pages::Message::SetInstanceName(_)
|
||||
| pages::Message::SetCategory(_)
|
||||
// M107 的四支同属下载页(查询 / 清空 / 主版本桶 / 折叠类型栏)
|
||||
| pages::Message::SetVersionQuery(_)
|
||||
| pages::Message::ClearVersionQuery
|
||||
| pages::Message::SelectMajor(_)
|
||||
| pages::Message::ToggleTypeRail => debug_assert!(false, "应由 on_download 消费"),
|
||||
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
|
||||
// 不派业务命令、不写持久状态)
|
||||
pages::Message::Action(pages::Action::ClearLogs) => {
|
||||
self.state.log_lines.clear();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
// M157:实例页「启动参数」区(编辑写进页面状态;保存才派发命令)
|
||||
pages::Message::Instances(message) => return self.on_instance_message(message),
|
||||
// M114:账户页(三轨登录 / 列表动作 / 表单输入)
|
||||
pages::Message::Account(message) => return self.on_account(message),
|
||||
// M188:日志页与设置页的「打开日志文件夹」——同一个落点,路径从设置桥反推
|
||||
// (三模式都对),失败文本带完整路径,并**送进剪贴板**(打不开时用户至少能粘出去)。
|
||||
pages::Message::Action(pages::Action::OpenLogs) => {
|
||||
return match crate::app::logs_folder::open() {
|
||||
Ok(dir) => {
|
||||
diag!("[logs] 已打开日志文件夹 {}", dir.display());
|
||||
Task::none()
|
||||
}
|
||||
Err(error) => {
|
||||
diag!("[logs] 打开日志文件夹失败:{error}");
|
||||
iced::clipboard::write(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||||
pages::Message::Action(action) => {
|
||||
diag!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。
|
||||
pub(in crate::app) fn lang(&self) -> Lang {
|
||||
self.lang.lang()
|
||||
}
|
||||
|
||||
/// 设置页语言行要的两件:档位(画选中态)+ 生效语言(画回显)。
|
||||
pub(in crate::app) fn language(&self) -> pages::Language {
|
||||
self.lang.for_settings()
|
||||
}
|
||||
|
||||
/// 换界面语言(设置页语言行 → `pages::Message::Language`)。
|
||||
///
|
||||
/// 三件事缺一不可:①档位与结论换掉 ②视图模型重建(`view_dirty` + `refresh_view`,目录是
|
||||
/// 视图模型的一部分)③落盘(重启后保持所选语言)。字体不在这里重扫:中文档缺字体时的
|
||||
/// 判据/信号归 `i18n::font::decide` + `fonts/cjk.rs`(见报告「没验的部分」)。
|
||||
pub(in crate::app) fn set_language(&mut self, choice: LangChoice) {
|
||||
if choice == self.lang.choice() {
|
||||
return;
|
||||
}
|
||||
self.lang = LangState::of(choice);
|
||||
self.lang.log();
|
||||
if !self.bridge.persist_ui_language(choice.setting_value()) {
|
||||
diag!("[i18n] 语言偏好没落盘(配置只读或数据根解析不出来):本次生效,重启会回到旧值");
|
||||
}
|
||||
self.lang.log_cjk();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同型。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// M106:设置页语言行点一下 → 生效语言与视图模型都跟着换。
|
||||
///
|
||||
/// 抓得住:①`pages::Message::Language` 没接进 `on_page`(点了没反应,编译器不会报——它接得住,
|
||||
/// 但接错了分支就静默);②`set_language` 只改字段不重建视图(界面要等下一次刷新才变);
|
||||
/// ③档位与生效语言各说各话(设置页会画错选中态)。
|
||||
/// 抓不住:界面真的画成中文了没有(那要截图,见 `DevDocs/artifacts/m106/`)。
|
||||
#[test]
|
||||
fn the_language_message_switches_the_catalog_and_rebuilds_the_view() {
|
||||
let mut app = headless();
|
||||
assert_eq!(
|
||||
app.view.lang,
|
||||
app.lang(),
|
||||
"首帧的视图模型要已经按生效语言建好"
|
||||
);
|
||||
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::ZhCn)),
|
||||
));
|
||||
assert_eq!(app.lang(), unlucky_i18n::Lang::ZhCn);
|
||||
assert_eq!(app.view.lang, unlucky_i18n::Lang::ZhCn, "目录要跟着换");
|
||||
assert_eq!(
|
||||
app.language(),
|
||||
pages::Language {
|
||||
choice: LangChoice::Fixed(unlucky_i18n::Lang::ZhCn),
|
||||
effective: unlucky_i18n::Lang::ZhCn,
|
||||
}
|
||||
);
|
||||
|
||||
let refreshes = app.refreshes;
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
|
||||
));
|
||||
assert_eq!(app.view.lang, unlucky_i18n::Lang::En);
|
||||
assert_eq!(app.refreshes, refreshes + 1, "换语言要重建一次视图模型");
|
||||
|
||||
// 同一档再点一次:什么都不该发生(不白重建)
|
||||
let refreshes = app.refreshes;
|
||||
let _ = app.update(crate::app::message::Message::Page(
|
||||
pages::Message::Language(LangChoice::Fixed(unlucky_i18n::Lang::En)),
|
||||
));
|
||||
assert_eq!(app.refreshes, refreshes, "同一档不该再重建");
|
||||
}
|
||||
|
||||
/// 抓得住:有人给 `on_page` 的 match 重新加 `_ => {}` 兜底 —— 那正是 M94 复审的 p3:
|
||||
/// 兜底让「加了变体忘了接线」从**编译错误**退化成「点了没反应」,这条测试把它拽回来。
|
||||
/// 抓不住:别的兜底写法(把 match 拆成 if-else 链、给子枚举加通配之后再漏变体),
|
||||
/// 也抓不住「加了变体但接错分支」——那两条只有编译器和人读得出来。
|
||||
#[test]
|
||||
fn the_page_match_has_no_wildcard_fallback() {
|
||||
let source = include_str!("page_messages.rs");
|
||||
let wildcards: Vec<(usize, &str)> = source
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.starts_with("_ =>") || trimmed.starts_with("_=>")
|
||||
})
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
wildcards.is_empty(),
|
||||
"on_page 里出现了 `_ =>` 兜底(行 {:?}):它会取消对 pages::Message 的穷尽性,\
|
||||
新变体漏接线时编译器不再报 E0004",
|
||||
wildcards
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 设置页分组消息的**二级分发**(M101 `SETTINGSMSG`):
|
||||
//! `pages::Message::Settings(GroupMessage)` → 四条填页线各自的 handler。
|
||||
//!
|
||||
//! ## 为什么分两级
|
||||
//!
|
||||
//! `app/update/page_messages.rs` 对 [`pages::Message`] 做穷尽匹配(没有 `_ =>`,有
|
||||
//! `the_page_match_has_no_wildcard_fallback` 守门)。设置页的四条填页线若各自往 `pages::Message`
|
||||
//! 里加变体,就都得改那个文件——四路并行必然冲突。M101 把它们收进一个投递口
|
||||
//! `pages::Message::Settings(GroupMessage)`,本文件再把 `GroupMessage` 分给四条线:
|
||||
//!
|
||||
//! ```text
|
||||
//! GroupMessage::Java(m) → settings/java.rs
|
||||
//! GroupMessage::Download(m) → settings/download.rs
|
||||
//! GroupMessage::LanguageAppearance(m) → settings/language_appearance.rs
|
||||
//! GroupMessage::AiAbout(m) → settings/ai_about.rs
|
||||
//! ```
|
||||
//!
|
||||
//! ## 穷尽性:抓得住什么 / 抓不住什么
|
||||
//!
|
||||
//! - **抓得住**:某条线给 `GroupMessage` 加了载荷变体(= 在自己文件里加了一支消息)却忘了在本
|
||||
//! match 里接线 → **E0004**;四个 handler 里对空枚举的 `match message {}` 一旦有人写成
|
||||
//! `_ => {}`,`the_group_dispatch_has_no_wildcard_fallback` 会红(兜底会把「漏接线」从编译
|
||||
//! 错误降级成「点了没反应」,`page_messages.rs` 的同类守卫是这么写的,这里照抄)。
|
||||
//! - **抓不住**:handler 忽略自己的消息(`let _ = message; Task::none()`)、两个 handler 身体写反
|
||||
//! 或写成一样——类型对得上就编得过,只有人读能看出来。也因此「接错了组」的一半是编译期兜住的
|
||||
//! (载荷类型不同 ⇒ 编不过),另一半(接了个空壳)不是。
|
||||
//!
|
||||
//! ## 加一支设置页消息要改哪
|
||||
//!
|
||||
//! 自己那条线的两个文件:消息口(`pages/settings/<线>.rs`)+ 落点(本目录 `<线>.rs`)。
|
||||
//! 本文件的 match 只在 `GroupMessage` **加变体**时才动,而那意味着新增一条线,不是加一支消息。
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 设置页的分组消息:分给四条线各自的文件,本文件不认识任何一支具体消息。
|
||||
pub(super) fn on_settings(&mut self, message: pages::settings::GroupMessage) -> Task<Message> {
|
||||
match message {
|
||||
pages::settings::GroupMessage::Java(message) => self.on_settings_java(message),
|
||||
pages::settings::GroupMessage::Download(message) => self.on_settings_download(message),
|
||||
pages::settings::GroupMessage::LanguageAppearance(message) => {
|
||||
self.on_settings_language_appearance(message)
|
||||
}
|
||||
pages::settings::GroupMessage::AiAbout(message) => self.on_settings_ai_about(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod ai_about;
|
||||
mod download;
|
||||
mod java;
|
||||
mod language_appearance;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
|
||||
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
|
||||
//!
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
|
||||
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
|
||||
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
|
||||
//!
|
||||
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
|
||||
//!
|
||||
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
|
||||
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
|
||||
//! (`SettingStore::set` + `save`),只是不经过那条通道。
|
||||
//!
|
||||
//! ## 为什么「测试」读文件而不是读草稿
|
||||
//!
|
||||
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
|
||||
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::ai::{Probe, ProbeResult};
|
||||
|
||||
impl App {
|
||||
/// 助手分组的一条消息。
|
||||
pub(super) fn on_settings_ai_about(
|
||||
&mut self,
|
||||
message: pages::settings::ai_about::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::ai_about::Message;
|
||||
|
||||
match message {
|
||||
Message::ToggleEnabled(enabled) => {
|
||||
self.settings.ai_about.enabled = enabled;
|
||||
self.touch();
|
||||
}
|
||||
Message::PickProvider(provider) => {
|
||||
self.settings.ai_about.provider = provider;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditBaseUrl(value) => {
|
||||
self.settings.ai_about.base_url = value;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditModel(value) => {
|
||||
self.settings.ai_about.model = value;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditKey(secret) => {
|
||||
self.settings.ai_about.key = secret;
|
||||
self.touch();
|
||||
}
|
||||
Message::Reveal(reveal) => {
|
||||
self.settings.ai_about.reveal = reveal;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Message::Save => return self.save_ai_settings(),
|
||||
Message::Test => return self.test_ai_connection(),
|
||||
Message::ProbeDone(result) => {
|
||||
self.settings.ai_about.probe = Probe::Done(*result);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch(&mut self) {
|
||||
self.settings.ai_about.dirty = true;
|
||||
self.settings.ai_about.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
|
||||
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
|
||||
/// 否则外部一改文件就把用户正在输入的内容冲掉。
|
||||
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
|
||||
if self.options.screenshot.is_some() {
|
||||
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
|
||||
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
|
||||
self.settings.ai_about = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
return;
|
||||
};
|
||||
let snapshot = pages::settings::ai::store::load(&path);
|
||||
if self.settings.ai_about.hydrate(snapshot) {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
|
||||
fn save_ai_settings(&mut self) -> Task<Message> {
|
||||
use pages::settings::ai::{store, SaveNotice};
|
||||
|
||||
if let Err(refusal) = self.settings.ai_about.validate() {
|
||||
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let saved = store::save(&path, &self.settings.ai_about);
|
||||
match saved {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.ai_about.dirty = false;
|
||||
self.settings.ai_about.hydrate(snapshot);
|
||||
self.settings.ai_about.key = pages::settings::ai::Secret::default();
|
||||
self.settings.ai_about.notice = Some(SaveNotice::Saved);
|
||||
}
|
||||
Err(reason) => {
|
||||
self.settings.ai_about.notice =
|
||||
Some(pages::settings::ai::SaveNotice::Failed(reason));
|
||||
}
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
|
||||
fn test_ai_connection(&mut self) -> Task<Message> {
|
||||
if !self.settings.ai_about.can_test() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
|
||||
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
|
||||
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
|
||||
},
|
||||
0,
|
||||
));
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
self.settings.ai_about.probe = Probe::Running;
|
||||
self.view_dirty = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
let started = std::time::Instant::now();
|
||||
let outcome = unlucky_cli::commands::agent::probe::probe_file(
|
||||
&path,
|
||||
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
|
||||
);
|
||||
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
|
||||
},
|
||||
|result| {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::AiAbout(
|
||||
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
|
||||
),
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Downloads 分组消息的落点(M201):**设置写回**的那一段(M101 `SETTINGSMSG` 的漏斗不变)。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/download.rs`(`pages::settings::download::Message`)。本文件只有一件事:
|
||||
//! 把「用户改了哪个键」翻成 `AppCommand::SetSetting` —— 那是 presenter 已有的写回闭环
|
||||
//! (`host/dispatch.rs` 的 `SetSetting` 分支:`settings::set` 落盘 → `apply_tuning` → 重新发
|
||||
//! `CoreEvent::SettingsChanged`)。
|
||||
//!
|
||||
//! ## 不做乐观回显
|
||||
//!
|
||||
//! 这里**不**改任何「当前值」:界面上的值是 presenter 快照(`pages::Model.settings`)的投影,
|
||||
//! 只有 host 把 `SettingsChanged` 发回来才会变。理由与下载页的安装/启动同一条纪律——本地改一份、
|
||||
//! host 回来再改一次,两边打架时界面就会各说一套(尤其是写盘失败:乐观回显会让用户以为改成了)。
|
||||
//!
|
||||
//! ## 文本行为什么要「应用」才提交
|
||||
//!
|
||||
//! 见 `pages/settings/download.rs` 的文件头:每敲一个键写一次盘会把半截值落进去。所以
|
||||
//! `Edit` 只改本地草稿(`view_dirty` 都不用翻:草稿由 `States` 直接读),`Commit` 才派发。
|
||||
//! 提交之后草稿当场丢掉——快照随即成为唯一真值,写失败时行上会回到文件里的值 + presenter 的
|
||||
//! 错误 Toast 说清原因(比留着一个没生效的草稿更诚实)。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use unlucky_presenter::dto::{find_row, AppCommand};
|
||||
|
||||
impl App {
|
||||
/// Downloads 分组的一条消息。
|
||||
pub(super) fn on_settings_download(
|
||||
&mut self,
|
||||
message: pages::settings::download::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::download::Message;
|
||||
|
||||
match message {
|
||||
Message::Set { key, value } => {
|
||||
self.dispatch(AppCommand::SetSetting { key, value });
|
||||
Task::none()
|
||||
}
|
||||
Message::Edit { key, value } => {
|
||||
// 只改草稿:`body` 每一帧都从 `Groups.download` 读它,所以这里不必重建视图模型。
|
||||
self.settings.download.edit(&key, value);
|
||||
Task::none()
|
||||
}
|
||||
Message::Commit { key } => {
|
||||
let value = self.pending_text(&key);
|
||||
self.settings.download.clear(&key);
|
||||
self.dispatch(AppCommand::SetSetting { key, value });
|
||||
Task::none()
|
||||
}
|
||||
// 动作行(「重新检测地区」):键名与动作 id 都由 presenter 的快照给,取值被
|
||||
// `settings::set` 忽略(那条分支先认键、再解析值)。
|
||||
Message::Action { key } => {
|
||||
self.dispatch(AppCommand::SetSetting {
|
||||
key,
|
||||
value: String::new(),
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一行要提交的文本:本地草稿优先,否则用快照里的当前值(= 文件里的值)。
|
||||
///
|
||||
/// 空串是合法提交(「留空 = 跟随默认」是这一组真有的语义),所以只有**两者都缺**才返回空串。
|
||||
fn pending_text(&self, key: &str) -> String {
|
||||
self.settings
|
||||
.download
|
||||
.pending(key)
|
||||
.or_else(|| find_row(&self.view.settings, key).map(|row| row.value_text.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "download_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Downloads 组的**写回闭环**(M201):`控件 → Message → AppCommand::SetSetting → 真 host 写盘
|
||||
//! → CoreEvent::SettingsChanged → App 的快照与页面模型`。
|
||||
//!
|
||||
//! 为什么这条断言必须在 app 层:链子的两头都是「有状态的东西」——中间断在任何一环,页面层测试
|
||||
//! 都看不见(它只拿到一份已经画好的模型)。要问的是「用户点了以后,**盘上和 App 认的值**变了吗」,
|
||||
//! 而不是「按钮的 `on_press` 里有没有构造消息」。
|
||||
//!
|
||||
//! 两个后端都测,因为它们证明的事不同:
|
||||
//!
|
||||
//! | 用例 | 后端 | 证明 |
|
||||
//! |---|---|---|
|
||||
//! | 前四条 | 假后端(按 host 形状回快照) | 搬运:消息 → 命令、命令 → 快照、快照 → 模型 |
|
||||
//! | 最后一条 | **真 `CoreHost` + 临时数据根** | 整条链:命令 → `settings::set` → `launcher.json` 真的变了 |
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, SettingControl, SettingGroupView, SettingRowView,
|
||||
};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::pages::settings::download;
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 这一组要断言的那个键(与 presenter 快照里的键同名)。
|
||||
const CONCURRENCY: &str = "download.concurrency";
|
||||
/// 一个文本键(代理地址)。
|
||||
const PROXY_URL: &str = "download.proxy_url";
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 一份只有下载组的快照:并发 = `concurrency`,代理 = `proxy`。
|
||||
///
|
||||
/// 行形状照 presenter 的 `host::settings::download::group` 抄(choice + 档位选项,text + 原文),
|
||||
/// 只保留本文件断言要用的那两行——这里守的是 app 侧的搬运,不是 presenter 的行文案。
|
||||
fn snapshot(concurrency: &str, proxy: &str) -> Vec<SettingGroupView> {
|
||||
let options = ["4", "8", "16"];
|
||||
let selected = i32::try_from(
|
||||
options
|
||||
.iter()
|
||||
.position(|item| *item == concurrency)
|
||||
.unwrap_or(1),
|
||||
)
|
||||
.expect("装得下");
|
||||
vec![SettingGroupView::new("download", "下载与镜像(D7)")
|
||||
.with(SettingRowView::choice(
|
||||
CONCURRENCY,
|
||||
"并发数",
|
||||
"同时下载的文件数",
|
||||
&options,
|
||||
selected,
|
||||
))
|
||||
.with(SettingRowView::text(
|
||||
PROXY_URL,
|
||||
"HTTP 代理",
|
||||
"manual 模式生效",
|
||||
proxy,
|
||||
))]
|
||||
}
|
||||
|
||||
/// 记下令、并按 host 的形状回一条 `SettingsChanged` 的后端。
|
||||
struct Hostish {
|
||||
commands: Rc<RefCell<Vec<AppCommand>>>,
|
||||
queued: Vec<AppEvent>,
|
||||
/// 回事件时用的快照(`SetSetting` 之后换成「值真的变了」的那一份)。
|
||||
state: RefCell<(String, String)>,
|
||||
}
|
||||
|
||||
impl Backend for Hostish {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.queued)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.commands.borrow_mut().push(command.clone());
|
||||
if let AppCommand::SetSetting { key, value } = command {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if key == CONCURRENCY {
|
||||
state.0 = value;
|
||||
} else if key == PROXY_URL {
|
||||
state.1 = value;
|
||||
}
|
||||
// host 写盘之后重新发一份快照(`dispatch.rs` 的 `SetSetting` 分支就这一行)。
|
||||
self.queued
|
||||
.push(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
|
||||
&state.0, &state.1,
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个装着 [`Hostish`] 的 App + 那本命令账。
|
||||
fn app() -> (App, Rc<RefCell<Vec<AppCommand>>>) {
|
||||
let commands = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = Hostish {
|
||||
commands: Rc::clone(&commands),
|
||||
queued: Vec::new(),
|
||||
state: RefCell::new(("8".to_string(), "http://127.0.0.1:7890".to_string())),
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(backend),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// 首屏:收一份 host 的快照(产品路径在 `on_boot` 的 `RefreshAll` 之后就是这样)。
|
||||
app.state
|
||||
.apply(AppEvent::Core(CoreEvent::SettingsChanged(snapshot(
|
||||
"8",
|
||||
"http://127.0.0.1:7890",
|
||||
))));
|
||||
app.refresh_view();
|
||||
(app, commands)
|
||||
}
|
||||
|
||||
/// 一条设置页消息走完漏斗。
|
||||
fn send(app: &mut App, message: pages::settings::download::Message) {
|
||||
let message = pages::Message::Settings(pages::settings::GroupMessage::Download(message));
|
||||
let _ = app.on_page(message);
|
||||
}
|
||||
|
||||
/// **主用例**:点一个档位 ⇒ 命令发出去了 ⇒ host 回的快照进了 App ⇒ 页面模型跟着换。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// 1. handler 不派发 `AppCommand::SetSetting`(写回那一行被删)——这时假后端收不到命令、
|
||||
/// 也回不出事件,快照停在旧值,红(变异验证见报告);
|
||||
/// 2. `pages::Model` 不从快照取设置(`view::model` 那行 `settings:` 被删)——`app.view.settings`
|
||||
/// 是空的,`current_value` 返回 `None`,红;
|
||||
/// 3. App 侧**乐观回显**(点完就地改本地值、不等事件)——把假后端的回事件删掉后第 2 段仍会绿,
|
||||
/// 所以本用例的「红」只证明闭合回路;它抓不住「有没有多存一份本地值」这一半。
|
||||
///
|
||||
/// 抓不住:真实点击(`Chip` 的 `on_press` 是否被真的按下)、host 是否真的落盘(那是最后一条的活)。
|
||||
#[test]
|
||||
fn changing_a_choice_value_lands_in_the_app_snapshot_and_the_page_model() {
|
||||
let (mut app, commands) = app();
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("8"),
|
||||
"开局认的是 host 给的那份"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Set {
|
||||
key: CONCURRENCY.to_string(),
|
||||
value: "4".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, value } if key == CONCURRENCY && value == "4"
|
||||
)),
|
||||
"写回必须走已有的 `AppCommand::SetSetting`(实际下发:{:?})",
|
||||
commands.borrow()
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("4"),
|
||||
"App 的设置快照必须跟着 host 回来那份变(不是本地 State 变)"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
|
||||
Some("4"),
|
||||
"页面模型(这一帧要画的值)也必须是 4:控件画的是 `Model.settings`"
|
||||
);
|
||||
}
|
||||
|
||||
/// 文本行:`Edit` 只改草稿(**不发指令**),`Commit` 才带着草稿去写盘。
|
||||
///
|
||||
/// 抓得住:`Edit` 也派发(每敲一个键写一次盘)、`Commit` 用快照旧值而不是草稿(用户输入丢失)。
|
||||
/// 抓不住:输入框本身的按键路径(`text_field` 的 `on_input` 是组件既有实现)。
|
||||
#[test]
|
||||
fn typing_a_text_value_only_commits_on_demand() {
|
||||
let (mut app, commands) = app();
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Edit {
|
||||
key: PROXY_URL.to_string(),
|
||||
value: "http://127.0.0.1:1080".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
commands.borrow().is_empty(),
|
||||
"输入过程中不许写盘(半截值会落进 launcher.json)"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Commit {
|
||||
key: PROXY_URL.to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, value } if key == PROXY_URL && value == "http://127.0.0.1:1080"
|
||||
)),
|
||||
"提交时带的必须是**草稿**里的那一串(不是快照里的旧值):{:?}",
|
||||
commands.borrow()
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), PROXY_URL).as_deref(),
|
||||
Some("http://127.0.0.1:1080"),
|
||||
"提交后快照换成 host 回来的新值"
|
||||
);
|
||||
}
|
||||
|
||||
/// 动作行(「重新检测地区」):派发的是那一行的键,值留空(presenter 那条分支先认键、不看值)。
|
||||
///
|
||||
/// 抓得住:动作按钮被写成「什么都不发」、或者把键发成按钮文案。
|
||||
#[test]
|
||||
fn an_action_row_dispatches_its_key() {
|
||||
let (mut app, commands) = app();
|
||||
let key = "download.action.detect_region".to_string();
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Action { key: key.clone() },
|
||||
);
|
||||
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key: sent, .. } if sent == &key
|
||||
)),
|
||||
"动作行必须走「按 schema 键写设置」那一条既有通路:{:?}",
|
||||
commands.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// 快照里没有本组时,页面**不许**自己编一行(`controls_of` 空 = 退回演示行)。
|
||||
///
|
||||
/// 抓得住:把演示行当成真值画(M193 用户的投诉形状)。
|
||||
/// 抓不住:`settings/mod.rs` 那条退回路径本身(那是 `demo_card` 的事)。
|
||||
#[test]
|
||||
fn an_empty_snapshot_yields_no_controls() {
|
||||
let (mut app, _commands) = app();
|
||||
app.state.settings_groups.clear();
|
||||
app.refresh_view();
|
||||
|
||||
assert!(download::controls_of(app.settings_snapshot()).is_empty());
|
||||
// 有快照时,本组的控件种类就是 presenter 给的那几行(这里两份:choice + text)。
|
||||
let kinds = download::controls_of(&snapshot("8", ""));
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![SettingControl::Choice, SettingControl::Text],
|
||||
"控件种类照快照的 `control` 字段画,不许自己另定"
|
||||
);
|
||||
}
|
||||
|
||||
/// **真 host 端到端**:点「16」⇒ 「设置页里改了就生效」这句话的正面证据。
|
||||
///
|
||||
/// 链子全长:`Message::Set` → `AppCommand::SetSetting` → 真 `CoreHost` 的 `dispatch.rs` 分支
|
||||
/// (`settings::set` 校验 + 落盘 + `apply_tuning` + 重新发 `SettingsChanged`)→ `AppState` →
|
||||
/// `pages::Model::settings`。断言落在**磁盘上那串字节**上,不是任何中间变量。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// 1. 只改本地、不派发(`Message::Set` 的分支被删)⇒ `launcher.json` 里没有 16,红;
|
||||
/// 2. 派发了但 handler 用了错误的键/值 ⇒ 文件里那个键还是出厂 8,红;
|
||||
/// 3. 快照没跟着 host 的回流走(`view::model` 那行 `settings:` 被删)⇒ 页面模型空,红;
|
||||
/// 4. 写盘校验被绕过(`settings::set` 不再 `save()`)⇒ 文件不存在/没变,红。
|
||||
///
|
||||
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑,`Chip::filter` 的 `on_press` 不在其中)、
|
||||
/// 引擎是否立刻吃到新值(`apply_tuning` 与 `concurrency_probe` 由 presenter 的
|
||||
/// `the_concurrency_setting_reaches_the_real_download_engine` 守)、以及界面像素(截图的事)。
|
||||
#[test]
|
||||
fn a_choice_change_reaches_the_real_settings_file() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m201-writeback-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
|
||||
let config = dir.join(".ULL/config/launcher.json");
|
||||
|
||||
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
|
||||
let mut app = App::with_presenter(
|
||||
Options {
|
||||
data_dir: Some(dir.clone()),
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(unlucky_presenter::host::CoreHost::new(env)),
|
||||
// 设置桥也指向同一个根:不碰用户的真配置。
|
||||
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
|
||||
);
|
||||
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
|
||||
app.dispatch(AppCommand::RefreshAll);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("8"),
|
||||
"临时根里没有这个键 ⇒ 快照给的是出厂 8(也是引擎的生效值)"
|
||||
);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
pages::settings::download::Message::Set {
|
||||
key: CONCURRENCY.to_string(),
|
||||
value: "16".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
|
||||
assert!(
|
||||
written.contains("\"concurrency\": 16"),
|
||||
"磁盘上必须真的有 16(这才是「设置页里改了就生效」):{written}"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(app.settings_snapshot(), CONCURRENCY).as_deref(),
|
||||
Some("16"),
|
||||
"host 把新快照发回来了,App 认的也是 16"
|
||||
);
|
||||
assert_eq!(
|
||||
download::current_value(&app.view.settings, CONCURRENCY).as_deref(),
|
||||
Some("16"),
|
||||
"页面模型也换成了 16(下一帧控件画的就是它)"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
|
||||
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
|
||||
//!
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
|
||||
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
|
||||
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
|
||||
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
|
||||
//!
|
||||
//! ## 为什么「设为默认」不攒到「保存」里
|
||||
//!
|
||||
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
|
||||
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
|
||||
//! 界面还标着旧的默认值,只会以为没生效。
|
||||
//!
|
||||
//! ## 版本门槛(「为什么选它」那句话的数据)
|
||||
//!
|
||||
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
|
||||
use super::super::*;
|
||||
use crate::app::diag::diag;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
/// M197 的两个落点(各自一个 `impl App` 块):真下载一个 JDK / 三个设置键。
|
||||
mod downloads;
|
||||
mod prefs;
|
||||
|
||||
impl App {
|
||||
/// Java 分组的一条消息。
|
||||
pub(super) fn on_settings_java(
|
||||
&mut self,
|
||||
message: pages::settings::java::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::java::Message;
|
||||
|
||||
match message {
|
||||
Message::Scan => return self.start_java_scan(),
|
||||
Message::CancelScan => {
|
||||
self.settings.java.scan.cancel();
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
Message::AddDraft(value) => {
|
||||
self.settings.java.add_draft = value;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Message::Add => self.add_java_manual(),
|
||||
Message::SelectRuntime(binary) => self.select_java_default(&binary),
|
||||
Message::EditMemory(value) => {
|
||||
self.settings.java.memory = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::EditJvm(value) => {
|
||||
self.settings.java.jvm = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::Save => self.save_java_settings(),
|
||||
// M197:真下载一个 JDK + 三个设置键。落点各自一个 `impl App` 块(见 `mod` 声明)。
|
||||
Message::Download(major) => return downloads::on_download(self, major),
|
||||
Message::EditInstallRoot(value) => return prefs::on_edit_install_root(self, value),
|
||||
Message::ToggleAutoInstall(on) => return prefs::on_toggle_auto_install(self, on),
|
||||
Message::MoveVendor { index, up } => return prefs::on_move_vendor(self, index, up),
|
||||
Message::ResetPriority => return prefs::on_reset_priority(self),
|
||||
Message::SavePrefs => return prefs::on_save_prefs(self),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 三键写盘成功之后,请 presenter 重发一次设置快照(M204)。
|
||||
///
|
||||
/// ## 为什么需要这一拍
|
||||
///
|
||||
/// 本组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
|
||||
/// 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`),
|
||||
/// **不经过** `AppCommand::SetSetting` 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处
|
||||
/// 发出(`dispatch.rs` 的 `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)——不请一次,
|
||||
/// `AppState::settings_groups` 里 java 组那几行会停在旧值,直到下一次任何别的写入。
|
||||
///
|
||||
/// ## 为什么不是 `AppCommand::RefreshAll`
|
||||
///
|
||||
/// `refresh_all()` 除重发快照之外还会 `versions::spawn_list`(拉版本清单)与
|
||||
/// `start_update_check`(自更新检查)——每点一次「保存内存」打两个网络请求,代价与动作不相称。
|
||||
/// 动作键 `JAVA_REFRESH_ACTION` 走的是同一条 `SetSetting` 分支的四个副作用臂,不写盘、不发网络。
|
||||
///
|
||||
/// ## 抓得住 / 抓不住
|
||||
///
|
||||
/// 抓得住:`tests/java_refresh.rs` 的 `saving_the_java_form_refreshes_the_host_settings_snapshot`
|
||||
/// 用**真 `CoreHost` + 临时数据根**跑完整链,摘掉这一行的 dispatch 就红(实测)。
|
||||
/// 抓不住:presenter 是否会因为**别的原因**(下一次外部写盘)自己刷新——所以那条用例的判据是
|
||||
/// 「写盘后**当场**快照里有新值」,而不是「最终会有」。
|
||||
fn request_settings_refresh(&mut self) {
|
||||
self.dispatch(AppCommand::SetSetting {
|
||||
key: unlucky_presenter::dto::JAVA_REFRESH_ACTION.to_string(),
|
||||
value: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch_java(&mut self) {
|
||||
self.settings.java.dirty = true;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
|
||||
///
|
||||
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
|
||||
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
|
||||
///
|
||||
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
|
||||
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
|
||||
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
|
||||
pub(in crate::app) fn hydrate_java_settings(&mut self) {
|
||||
let seeded = self.options.screenshot.is_some();
|
||||
if seeded {
|
||||
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
|
||||
if raw.trim() != "live" {
|
||||
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
|
||||
self.settings.java = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `ULL_JAVA_STATE=live`:不灌种子,继续往下走真路径(读本机配置 + 真扫盘)。
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java = pages::settings::java::State::default();
|
||||
return;
|
||||
};
|
||||
let snapshot = pages::settings::java::store::load(&config);
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
let changed = self.settings.java.hydrate(snapshot);
|
||||
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)+ 它们的生效值与来源层。
|
||||
let prefs_changed = self
|
||||
.settings
|
||||
.java
|
||||
.prefs
|
||||
.hydrate(pages::settings::java::prefs_store::load(&config));
|
||||
self.settings.java.effective =
|
||||
pages::settings::java::prefs_store::effective(&config, &self.settings.java.prefs);
|
||||
let scan_changed = self.hydrate_java_scan();
|
||||
self.settings.java.context = self.java_context();
|
||||
if changed || prefs_changed || scan_changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 自动扫一次(首屏就该有真数据)。**没有 `cfg!(test)` 分叉**:单测走的也是这条生产入口,
|
||||
// 删掉这一行 / 把实参翻成 `true` ⇒ `tests/java.rs` 的
|
||||
// `the_first_hydrate_starts_a_real_scan_by_itself` 立刻红(r3 复审 P3-3 的缺口)。
|
||||
//
|
||||
// 实参为什么恒为 `false`:截图档的 `empty|two|editing` 在上面就 `return` 了,能走到这里的
|
||||
// 只有「不是截图」与「`live` 档」两种,而 `live` 按产品路径算(见本函数文档 D30 的例外)。
|
||||
self.auto_scan_java_if_due(false);
|
||||
}
|
||||
|
||||
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
|
||||
///
|
||||
/// 抽出来是为了让「取消过就不再自动扫」(r2 复审 P2-2)有个窄的落点;而**首屏真的调了它**
|
||||
/// 这件事由生产入口自己守(`tests/java.rs` 的那条首屏用例),不再靠 `cfg!(test)` 把调用关掉。
|
||||
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
|
||||
let _ = self.start_java_scan();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||
///
|
||||
/// M197 起这一拍**也**灌一次下载作业的快照:`app/update/clock.rs` 每拍只调这一个入口,
|
||||
/// 而复用它比改心跳的调用点便宜(`clock.rs` 不在本 mission 的 scope 里)。
|
||||
/// 代价写在 `settings/java/downloads.rs`:下载期间进度按 1 Hz 刷新。
|
||||
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
let mut changed = self.settings.java.apply_scan(&report);
|
||||
if self.hydrate_java_downloads() {
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
|
||||
fn start_java_scan(&mut self) -> Task<Message> {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
let manual = pages::settings::java::store::load_manual(&config)
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
let roots = pages::settings::java::store::managed_roots(&config);
|
||||
self.settings.java.notice = None;
|
||||
if self.settings.java.scan.start(roots, manual, true) {
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
|
||||
fn add_java_manual(&mut self) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
let draft = self.settings.java.add_draft.clone();
|
||||
match pages::settings::java::store::add_manual(&config, &draft) {
|
||||
Ok((binary, row)) => {
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
if !self
|
||||
.settings
|
||||
.java
|
||||
.runtimes
|
||||
.iter()
|
||||
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
|
||||
{
|
||||
self.settings.java.runtimes.push(row);
|
||||
self.settings
|
||||
.java
|
||||
.runtimes
|
||||
.sort_by_key(|runtime| runtime.major);
|
||||
}
|
||||
self.settings.java.add_draft = String::new();
|
||||
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
|
||||
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
|
||||
self.settings.java.scanned = true;
|
||||
self.settings.java.notice = Some(Notice::Added(binary));
|
||||
}
|
||||
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
|
||||
fn select_java_default(&mut self, binary: &str) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::set_selected(&config, binary) {
|
||||
Ok(snapshot) => {
|
||||
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
|
||||
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
|
||||
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
|
||||
// M204:这一笔也写进 launcher.json 了(`java.selected_path`),请 host 重发快照。
|
||||
self.request_settings_refresh();
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
|
||||
fn save_java_settings(&mut self) {
|
||||
let state = &self.settings.java;
|
||||
if let Err(refusal) = state.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::save(&config, &self.settings.java) {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.java.dirty = false;
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::Saved);
|
||||
// M204:内存 + JVM 参数已经落盘(一次 `store.save()`),请 host 重发快照。
|
||||
self.request_settings_refresh();
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
|
||||
///
|
||||
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
|
||||
/// (页面就不画门槛那一行,也不假装有要求)。
|
||||
fn java_context(&self) -> Context {
|
||||
let tiles = self.state.tiles();
|
||||
let routed = match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => {
|
||||
tiles.iter().find(|cell| &cell.instance.id == id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(cell) = routed.or_else(|| tiles.first()) else {
|
||||
return Context::default();
|
||||
};
|
||||
let instance = cell.instance;
|
||||
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
|
||||
Context {
|
||||
required: Some(Requirement {
|
||||
instance: instance.name.clone(),
|
||||
version: instance.mc_version.clone(),
|
||||
minimum: requirement.minimum,
|
||||
maximum: requirement.maximum,
|
||||
}),
|
||||
layer: Some(match instance.launch.memory_mb {
|
||||
Some(mb) => Layer::Instance {
|
||||
instance: instance.name.clone(),
|
||||
mb,
|
||||
},
|
||||
None => Layer::Global,
|
||||
}),
|
||||
effective_memory_mb: Some(instance.launch.memory_effective_mb),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写盘失败 → 回执:三档都走 i18n key。
|
||||
///
|
||||
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
|
||||
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
|
||||
/// 界面上给一句页面自己的「写不进去」。
|
||||
///
|
||||
/// `pub(super)`:`tests/java.rs` 直接钉这一层的映射(M199 起页面侧已经撞不到 schema 那一档)。
|
||||
pub(super) fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||
use pages::settings::java::store::WriteError;
|
||||
match error {
|
||||
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||
WriteError::Schema(detail) => {
|
||||
diag!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 「下载一个 JDK」的 App 层落点(M197)。
|
||||
//!
|
||||
//! 三件事:
|
||||
//!
|
||||
//! | 函数 | 谁调 | 干什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`App::start_java_download`] | `Download(major)` 消息 | 本地判据(目录/承接方/落点/是否已在跑)→ 起后台作业 |
|
||||
//! | [`App::hydrate_java_downloads`] | 心跳那一拍(经 `hydrate_java_scan`) | 把作业快照灌成视图差量;**刚成功**时重扫 + 按 `java.auto_install` 决定要不要设为默认 |
|
||||
//!
|
||||
//! ## 为什么轮询挂在 `hydrate_java_scan` 上
|
||||
//!
|
||||
//! M175 的扫描已经是「后台线程 + 快照 + 心跳比对」的形状,`app/update/clock.rs` 每拍只调
|
||||
//! `hydrate_java_scan()` 这一个入口。下载作业是同一族东西(后台线程 + 快照),所以复用那一拍,
|
||||
//! `clock.rs` 一个字节都不用改——代价是**下载期间进度按 1 Hz 刷新**(帧时钟的判据在
|
||||
//! `app/subscription.rs` 的 `needs_frames`,那是本 mission scope 之外的文件)。百分比一秒动一次
|
||||
//! 对一次 100 MB 级的下载是够的;要紧的是它**会动**,而不是每秒动 60 次。
|
||||
//!
|
||||
//! ## 装完之后为什么要重扫
|
||||
//!
|
||||
//! 列表里那一行「已安装:…」来自**真扫盘的结论**(M175 的 `ScanSlot`),不是另存的安装记录。
|
||||
//! 装完不重扫,界面上就会出现「下载成功」与「列表里没有它」并存——正是本仓最忌讳的那种自相矛盾。
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_core::game::java::catalog;
|
||||
|
||||
use crate::pages::settings::java::{prefs_store, store};
|
||||
|
||||
impl App {
|
||||
/// 下载并安装某个大版本(`Download(major)` 的落点)。
|
||||
///
|
||||
/// 四道本地判据,任何一道不过都在下载区顶上写清原因(点了没反应是禁止的):
|
||||
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
|
||||
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
|
||||
pub(super) fn start_java_download(&mut self, major: u32) {
|
||||
use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request};
|
||||
|
||||
self.settings.java.download_notice = None;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
|
||||
if catalog::find(major).is_none() {
|
||||
self.settings.java.download_notice = Some(Refusal::UnknownMajor);
|
||||
return;
|
||||
}
|
||||
if self.settings.java.downloads.busy() {
|
||||
self.settings.java.download_notice = Some(Refusal::Busy);
|
||||
return;
|
||||
}
|
||||
let Some(config) = store::config_of(&self.bridge) else {
|
||||
self.settings.java.download_notice = Some(Refusal::NoStorage);
|
||||
return;
|
||||
};
|
||||
let priority = self.settings.java.priority_vendors();
|
||||
let domestic = self.settings.java.effective.domestic;
|
||||
if catalog::vendors_for(major, &priority, domestic).is_empty() {
|
||||
self.settings.java.download_notice = Some(Refusal::NoVendor);
|
||||
return;
|
||||
}
|
||||
let saved_root = self.settings.java.prefs.saved_install_root.clone();
|
||||
let Some(cache_root) = prefs_store::cache_root(&config) else {
|
||||
self.settings.java.download_notice = Some(Refusal::NoStorage);
|
||||
return;
|
||||
};
|
||||
let Some(install_root) = prefs_store::install_root(&config, &saved_root) else {
|
||||
// 有设置文件但解析不出落点(standard 模式读不到系统目录):说这一句,
|
||||
// 而不是含糊的「没有可用的设置文件」。
|
||||
self.settings.java.download_notice = Some(Refusal::RootUnresolved);
|
||||
return;
|
||||
};
|
||||
let started = self.settings.java.downloads.start(Request {
|
||||
major,
|
||||
cache_root,
|
||||
install_root,
|
||||
priority,
|
||||
// `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认,
|
||||
// `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。
|
||||
gh_proxy: gh_proxy_of_config(&config),
|
||||
});
|
||||
if !started {
|
||||
self.settings.java.download_notice = Some(Refusal::Busy);
|
||||
}
|
||||
}
|
||||
|
||||
/// 把作业快照灌成视图差量(心跳那一拍调)。返回是否变了。
|
||||
pub(in crate::app) fn hydrate_java_downloads(&mut self) -> bool {
|
||||
let report = self.settings.java.downloads.report();
|
||||
if report == self.settings.java.download_seen {
|
||||
return false;
|
||||
}
|
||||
let fresh = report
|
||||
.success
|
||||
.clone()
|
||||
.filter(|success| self.settings.java.download_seen.success.as_ref() != Some(success));
|
||||
self.settings.java.download_seen = report;
|
||||
if let Some(success) = fresh {
|
||||
// 新装上的一份:先重扫(列表要出现它),再按 `java.auto_install` 决定默认值。
|
||||
let _ = self.start_java_scan();
|
||||
if self.settings.java.prefs.saved_auto_install
|
||||
&& self.settings.java.selected.trim().is_empty()
|
||||
{
|
||||
self.select_java_default(&success.binary);
|
||||
}
|
||||
}
|
||||
self.view_dirty = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
|
||||
pub(super) fn on_download(app: &mut App, major: u32) -> Task<Message> {
|
||||
app.start_java_download(major);
|
||||
Task::none()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 三个设置键的 App 层落点(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
|
||||
//!
|
||||
//! ## 为什么这三项单独一个「保存」
|
||||
//!
|
||||
//! M175 的「保存」只写内存 + JVM 参数(`java.default_memory_mb` / `java.extra_args`),那是**运行时
|
||||
//! 参数**;这三项是**装到哪、按什么顺序找、缺 JDK 时怎么准备**——改了它们的副作用是「下一次下载 /
|
||||
//! 下一次扫描的落点变了」,与内存那两项混在一个按钮里,用户就不清楚自己刚保存的到底是哪一半。
|
||||
//! 于是两段各有自己的脏标记与保存按钮,各回读各的键。
|
||||
//!
|
||||
//! ## 保存之后为什么立刻重扫
|
||||
//!
|
||||
//! `java.installRoot` 改了 ⇒ 托管根的集合变了 ⇒ 「已检测到的 JDK」那份列表可能整份不同。
|
||||
//! 保存成功就重扫一次,界面不会出现「落点写着新目录,列表还是旧目录里的 JDK」。
|
||||
//!
|
||||
//! ## 回读校验
|
||||
//!
|
||||
//! `prefs_store::save` 返回的是**落盘后从磁盘读回来**的真值,[`Prefs::adopt`] 把草稿与
|
||||
//! 「落盘真值」两半都对齐到它:用户看到的就是磁盘上那份(要是 schema 把值规整过,界面显示的是
|
||||
//! 规整后的结果,而不是他刚敲的那串——这一条正是「改完回读验证已落盘」)。
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::pages;
|
||||
|
||||
use super::super::java::write_notice;
|
||||
|
||||
impl App {
|
||||
/// `java.installRoot` 草稿(只改草稿,不写盘)。
|
||||
pub(super) fn edit_java_install_root(&mut self, value: String) {
|
||||
self.settings.java.prefs.install_root = value;
|
||||
self.settings.java.notice = None;
|
||||
self.settings.java.download_notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// `java.auto_install` 开关(只改草稿)。
|
||||
pub(super) fn toggle_java_auto_install(&mut self, on: bool) {
|
||||
self.settings.java.prefs.auto_install = on;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 发行版顺序上移 / 下移一行。
|
||||
pub(super) fn move_java_vendor(&mut self, index: usize, up: bool) {
|
||||
let domestic = self.settings.java.effective.domestic;
|
||||
if up {
|
||||
self.settings.java.prefs.move_up(index, domestic);
|
||||
} else {
|
||||
self.settings.java.prefs.move_down(index, domestic);
|
||||
}
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 恢复地区默认序(= 把这个键写回空,让地区那一层重新生效)。
|
||||
pub(super) fn reset_java_priority(&mut self) {
|
||||
self.settings.java.prefs.reset_priority();
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存三个键:本地校验 → 落盘 → **用读回的真值**刷新草稿与生效值 → 重扫一次。
|
||||
pub(super) fn save_java_prefs(&mut self) {
|
||||
use pages::settings::java::Notice;
|
||||
|
||||
self.view_dirty = true;
|
||||
if let Err(refusal) = self.settings.java.prefs.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::prefs_store::save(&config, &self.settings.java.prefs) {
|
||||
Ok(snapshot) => {
|
||||
self.settings.java.prefs.adopt(snapshot);
|
||||
self.settings.java.effective = pages::settings::java::prefs_store::effective(
|
||||
&config,
|
||||
&self.settings.java.prefs,
|
||||
);
|
||||
// 落点 / 顺序变了:托管根的集合可能整份不同,立刻重扫一次。
|
||||
// 顺序要紧:`start_java_scan` 会清 `notice`,所以回执写在它之后。
|
||||
let _ = self.start_java_scan();
|
||||
self.settings.java.notice = Some(Notice::PrefsSaved);
|
||||
// M204:三键也已落盘(`java.installRoot` / `java.auto_install` / `java.vendorPriority`
|
||||
// 一次 `store.save()`),请 host 重发快照——否则 `AppState::settings_groups` 里
|
||||
// java 组那三行停在旧值,要等下一次任何别的写入才跟上。
|
||||
self.request_settings_refresh();
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
|
||||
pub(super) fn on_edit_install_root(app: &mut App, value: String) -> Task<Message> {
|
||||
app.edit_java_install_root(value);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `ToggleAutoInstall` 的转发。
|
||||
pub(super) fn on_toggle_auto_install(app: &mut App, on: bool) -> Task<Message> {
|
||||
app.toggle_java_auto_install(on);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `MoveVendor` 的转发。
|
||||
pub(super) fn on_move_vendor(app: &mut App, index: usize, up: bool) -> Task<Message> {
|
||||
app.move_java_vendor(index, up);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `ResetPriority` 的转发。
|
||||
pub(super) fn on_reset_priority(app: &mut App) -> Task<Message> {
|
||||
app.reset_java_priority();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `SavePrefs` 的转发。
|
||||
pub(super) fn on_save_prefs(app: &mut App) -> Task<Message> {
|
||||
app.save_java_prefs();
|
||||
Task::none()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 语言 + 外观两组消息的落点(M101 `SETTINGSMSG`):**只归 language+appearance 线**。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/language_appearance.rs`(`pages::settings::language_appearance::Message`)。
|
||||
//! 为什么两组共用一个消息口:它俩本就是同一条线的两个分组(General 的语言 / 主题 / 启动页,
|
||||
//! Appearance 的密度 / 圆角 / 动效 / 文字缩放),`GroupMessage` 只给这条线留一个变体。
|
||||
//!
|
||||
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
impl App {
|
||||
/// 语言 / 外观线的一条消息。首版没有可处理的东西(空枚举)。
|
||||
pub(super) fn on_settings_language_appearance(
|
||||
&mut self,
|
||||
message: pages::settings::language_appearance::Message,
|
||||
) -> Task<Message> {
|
||||
match message {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 设置页分组消息分发的**守门测试**(M101 `SETTINGSMSG`)。
|
||||
//!
|
||||
//! 判据与 `app/update/page_messages.rs` 里那条 `the_page_match_has_no_wildcard_fallback` 同源:
|
||||
//! 兜底会把「加了变体忘了接线」从**编译错误**降级成「点了没反应」。两级 dispatch 各守一条:
|
||||
//! 那一层守 `pages::Message`,本文件守 `GroupMessage` 与四个 handler。
|
||||
//!
|
||||
//! ## 判据(r2 复审 P2-3 之后收窄过一次)
|
||||
//!
|
||||
//! 上一版的判据是「除已知前缀外,任何含 `=>` 的行都算兜底」——**过宽**:往 handler 里加一个
|
||||
//! **没有兜底臂**的嵌套 match(`match true { true => …, false => … }`),或者 rustfmt 把长
|
||||
//! or-pattern 折行成 `| X =>`,都会让它假红。四条填页线一写真实逻辑就撞,守门反而推高成本。
|
||||
//!
|
||||
//! 现在的判据是**结构化的**:只找 `match message {` 这一层(用大括号深度计数把嵌套 match 排除
|
||||
//! 在外),**只看深度 1 的臂**——
|
||||
//!
|
||||
//! - `app/update/settings.rs` 的 `on_settings`:恰好 **4 条**臂,每条以 `pages::settings::GroupMessage::`
|
||||
//! (或短写 `GroupMessage::`)开头;
|
||||
//! - 四个 handler:每条臂以该组自己的 `pages::settings::<线>::Message::`(或短写 `Message::`)开头,
|
||||
//! 还没有臂的(空枚举 `match message {}`)一条臂都没有;
|
||||
//! - 以 `|` 开头的折行 or-pattern 续行放行(它不可能是兜底)。
|
||||
//!
|
||||
//! ## 抓得住什么 / 抓不住什么
|
||||
//!
|
||||
//! 抓得住:
|
||||
//! 1. `on_settings` 里加 `_ =>` 或 binding 臂 `other =>`(`GroupMessage` 不再穷尽);
|
||||
//! 2. handler 把 `match message {}` 改成 `_ => Task::none()` / `other => { … }`;
|
||||
//! 3. `on_settings` 的臂数不是 4(删了一条、或把两条合并);
|
||||
//! 4. handler 的臂写成别的组的变体路径(前缀不对)。
|
||||
//!
|
||||
//! 抓不住(老实列出来):
|
||||
//! 1. **嵌套 match 里的兜底**——深度 ≥2 的臂不看。这是刻意的:嵌套 match 的穷尽性由它自己的类型
|
||||
//! 保证,跟 `GroupMessage` 漏接线无关;代价是没人守「嵌套 match 自己写了个 `_ =>`」;
|
||||
//! 2. **跨行 pattern 分两种,别当一种**:以 `|` 开头的 or-pattern 续行**放行**(不误红);
|
||||
//! 但**花括号形状**的多行 pattern(例如 `Message::SetRuntime {` 折成多行、`=>` 落在后面)
|
||||
//! 会被**静默跳过**——那一行不带 `=>`,于是既不算臂也不算违规(臂数 0、零违规),是**漏检**
|
||||
//! 而不是误红。两种都是洞,当前代码风格都不出现;真要用这种写法时把规则补上,别把守门删掉;
|
||||
//! 3. **字符串/字符字面量里的 `{` `}`** 会扰乱深度计数(本文件与四个 handler 都没有这种写法);
|
||||
//! 4. handler 忽略消息(`let _ = message; Task::none()`)、两个 handler 身体写反或写成一样
|
||||
//! ——类型对得上就编得过,只有人读能看出来;
|
||||
//! 5. `match message` 被改名或换变量名时找不到匹配,臂数为 0 —— 这时
|
||||
//! `the_group_dispatch_has_exactly_four_arms` 会红(不是静默通过)。
|
||||
//!
|
||||
//! 站位:本文件是 `app::update::settings::tests`,`include_str!` 的路径相对本文件所在目录。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
/// `on_settings` 的臂允许的前缀。
|
||||
const GROUP_ARMS: &[&str] = &["pages::settings::GroupMessage::", "GroupMessage::"];
|
||||
|
||||
/// 一行的净大括号增量(`{` 加、`}` 减)。
|
||||
fn brace_delta(line: &str) -> i32 {
|
||||
let open = line.matches('{').count() as i32;
|
||||
let close = line.matches('}').count() as i32;
|
||||
open - close
|
||||
}
|
||||
|
||||
/// 扫一条 `match message { … }`,只看**本层**(深度 1)的臂。
|
||||
///
|
||||
/// 返回 `(本层的合法臂数, 违规行)`:违规 = 本层里含 `=>`、又不是「已知前缀开头」的行
|
||||
/// (`|` 开头的折行 or-pattern 续行放行)。嵌套 match 的臂在深度 ≥2,整段跳过。
|
||||
fn dispatch_arms(path: &str, source: &str, allowed: &[&str]) -> (usize, Vec<String>) {
|
||||
let mut arms = 0usize;
|
||||
let mut offending = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut inside = false;
|
||||
|
||||
for (index, line) in source.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !inside {
|
||||
if trimmed.contains("match message {") {
|
||||
inside = true;
|
||||
depth = 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if depth == 1 && trimmed.contains("=>") {
|
||||
let pattern = trimmed.trim_start_matches('|').trim_start();
|
||||
if allowed.iter().any(|prefix| pattern.starts_with(prefix)) {
|
||||
arms += 1;
|
||||
} else if !trimmed.starts_with('|') {
|
||||
offending.push(format!("{path}:{}: {trimmed}", index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
depth += brace_delta(trimmed);
|
||||
if depth <= 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(arms, offending)
|
||||
}
|
||||
|
||||
/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同一副骨架。
|
||||
fn headless() -> App {
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守门本体
|
||||
|
||||
/// `on_settings` 与四个 handler 的**本层**臂里不许出现任何形式的兜底(`_ =>` 或 `other =>`)。
|
||||
#[test]
|
||||
fn the_group_dispatch_has_no_catch_all_arm() {
|
||||
let sources: [(&str, &str, &[&str]); 5] = [
|
||||
(
|
||||
"app/update/settings.rs",
|
||||
include_str!("../settings.rs"),
|
||||
GROUP_ARMS,
|
||||
),
|
||||
(
|
||||
"app/update/settings/java.rs",
|
||||
include_str!("java.rs"),
|
||||
&["pages::settings::java::Message::", "Message::"],
|
||||
),
|
||||
(
|
||||
"app/update/settings/download.rs",
|
||||
include_str!("download.rs"),
|
||||
&["pages::settings::download::Message::", "Message::"],
|
||||
),
|
||||
(
|
||||
"app/update/settings/language_appearance.rs",
|
||||
include_str!("language_appearance.rs"),
|
||||
&[
|
||||
"pages::settings::language_appearance::Message::",
|
||||
"Message::",
|
||||
],
|
||||
),
|
||||
(
|
||||
"app/update/settings/ai_about.rs",
|
||||
include_str!("ai_about.rs"),
|
||||
&["pages::settings::ai_about::Message::", "Message::"],
|
||||
),
|
||||
];
|
||||
|
||||
let mut offending = Vec::new();
|
||||
for (path, source, allowed) in sources {
|
||||
let (_, bad) = dispatch_arms(path, source, allowed);
|
||||
offending.extend(bad);
|
||||
}
|
||||
|
||||
assert!(
|
||||
offending.is_empty(),
|
||||
"设置页分组消息的 dispatch 本层出现了兜底臂({offending:?}):\
|
||||
任何 catch-all / binding 臂都会取消 `GroupMessage` 的穷尽性,\
|
||||
新变体漏接线时编译器不再报 E0004"
|
||||
);
|
||||
}
|
||||
|
||||
/// `on_settings` 必须**恰好四条**臂,一条一条显式写全(不是「至少」)。
|
||||
///
|
||||
/// 抓得住:删掉一条臂、把两条合并、或者把 `match message` 改名(改名后扫不到臂 ⇒ 0 ≠ 4)。
|
||||
/// 抓不住:臂的顺序(顺序不影响语义)、每条臂接到了哪个 handler(类型对得上就编得过)。
|
||||
#[test]
|
||||
fn the_group_dispatch_has_exactly_four_arms() {
|
||||
let (arms, _) = dispatch_arms(
|
||||
"app/update/settings.rs",
|
||||
include_str!("../settings.rs"),
|
||||
GROUP_ARMS,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
arms, 4,
|
||||
"`on_settings` 应该恰好四条臂(java / download / language_appearance / ai_about),实际 {arms} 条"
|
||||
);
|
||||
}
|
||||
|
||||
/// **守门自身不误伤**:嵌套 match 与折行的 or-pattern 都不算兜底(r2 复审 P2-3 的探针)。
|
||||
///
|
||||
/// 这条是上一版判据假红的回归测试:复审往 handler 里加 `match true { true => …, false => … }`
|
||||
/// 就让它 FAILED;现在这类写法必须放行。
|
||||
#[test]
|
||||
fn the_guard_ignores_nested_matches_and_folded_or_patterns() {
|
||||
let synthetic = "\
|
||||
impl App {
|
||||
fn on_settings_java(&mut self, message: pages::settings::java::Message) -> Task<Message> {
|
||||
match message {
|
||||
pages::settings::java::Message::Probe => {
|
||||
let nested = match true { true => 10u8, false => 11u8 };
|
||||
let _ = nested;
|
||||
Task::none()
|
||||
}
|
||||
| pages::settings::java::Message::Retry => Task::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
let (arms, offending) = dispatch_arms(
|
||||
"synthetic",
|
||||
synthetic,
|
||||
&["pages::settings::java::Message::"],
|
||||
);
|
||||
assert!(
|
||||
offending.is_empty(),
|
||||
"正常代码被误判成兜底(这正是 r2 复审 P2-3 的假红):{offending:?}"
|
||||
);
|
||||
assert_eq!(arms, 2, "嵌套 match 里的两条臂不算本层臂");
|
||||
}
|
||||
|
||||
/// **守门仍然有牙**:`_ =>` 与 binding 臂 `other =>` 都要被本层判成兜底。
|
||||
#[test]
|
||||
fn the_guard_still_catches_both_catch_all_shapes() {
|
||||
for arm in [
|
||||
"_ => Task::none(),",
|
||||
"other => { let _ = other; Task::none() }",
|
||||
] {
|
||||
let synthetic = format!(
|
||||
"impl App {{\n fn on_settings_java(&mut self, message: M) -> Task<Message> {{\n match message {{\n {arm}\n }}\n }}\n}}\n"
|
||||
);
|
||||
let (_, offending) = dispatch_arms("synthetic", &synthetic, GROUP_ARMS);
|
||||
assert_eq!(
|
||||
offending.len(),
|
||||
1,
|
||||
"`{arm}` 是兜底,必须被判红(实际 {offending:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 活的端到端证据
|
||||
|
||||
/// **活的端到端证据**:一条组消息真的从外壳那条既有通道走到该组的 handler(r1 复审要求)。
|
||||
///
|
||||
/// 路径:`app::Message::Page` → `on_page` → `pages::Message::Settings` → `on_settings` →
|
||||
/// `on_settings_java`,最后改到 `App` 的状态。r1 之前这条链只有编译期证据(`GroupMessage` 是
|
||||
/// uninhabited,运行时是死代码);现在至少有一支真的跑得起来。
|
||||
///
|
||||
/// 抓得住:漏斗某一环接断了(`on_page` 没转交、`on_settings` 转错组、handler 忽略消息)——那时
|
||||
/// `view_dirty` 不会翻。抓不住:真实用户点击(本条没有 UI 控件,构造点只有测试)。
|
||||
#[test]
|
||||
fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
let mut app = headless();
|
||||
app.view_dirty = false;
|
||||
|
||||
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
|
||||
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
|
||||
let _ = app.update(Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
|
||||
"4096".to_string(),
|
||||
)),
|
||||
)));
|
||||
|
||||
assert!(
|
||||
app.view_dirty,
|
||||
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- M174:助手配置的活路径
|
||||
|
||||
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
|
||||
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-ai-app-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
(app, path)
|
||||
}
|
||||
|
||||
fn ai(message: pages::settings::ai_about::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::AiAbout(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。
|
||||
///
|
||||
/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。
|
||||
/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」
|
||||
/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。
|
||||
#[test]
|
||||
fn saving_the_ai_form_writes_the_real_settings_file() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("save");
|
||||
let _ = app.update(ai(Ai::PickProvider(
|
||||
pages::settings::ai::ProviderId::OpenAiCompatible,
|
||||
)));
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
|
||||
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
|
||||
"sk-live-app-path",
|
||||
))));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
|
||||
assert!(
|
||||
text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}");
|
||||
assert!(
|
||||
text.contains("\"provider\": \"openai_compatible\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}");
|
||||
|
||||
// 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。
|
||||
let state = &app.settings.ai_about;
|
||||
assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved));
|
||||
assert!(!state.dirty);
|
||||
assert!(state.key.is_empty());
|
||||
assert_eq!(state.key_chars, "sk-live-app-path".chars().count());
|
||||
let printed = format!("{state:?}");
|
||||
assert!(
|
||||
!printed.contains("sk-live-app-path"),
|
||||
"UI 状态泄漏密钥:{printed}"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。
|
||||
#[test]
|
||||
fn refusing_to_save_leaves_the_file_untouched() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("refuse");
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::EditModel("qwen".to_string())));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
let saved = std::fs::read_to_string(&path).expect("第一次应当写成");
|
||||
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
assert_eq!(
|
||||
app.settings.ai_about.notice,
|
||||
Some(pages::settings::ai::SaveNotice::Refused(
|
||||
pages::settings::ai::state::Refusal::BaseUrl
|
||||
)),
|
||||
"没有协议头的端点必须被拦下,且要说清原因"
|
||||
);
|
||||
let after = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。
|
||||
#[test]
|
||||
fn testing_without_configuration_does_nothing() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("no-test");
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
// 模型名空着 ⇒ `can_test()` 为假
|
||||
assert!(app.settings.ai_about.missing().is_some());
|
||||
let _ = app.update(ai(Ai::Test));
|
||||
assert_eq!(
|
||||
app.settings.ai_about.probe,
|
||||
pages::settings::ai::Probe::Idle,
|
||||
"未配置时点测试不该有任何探测态(更不该发请求)"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
|
||||
#[path = "tests/java.rs"]
|
||||
mod java;
|
||||
|
||||
/// M204:Java 组写盘之后请 host 重发快照(动作键 `JAVA_REFRESH_ACTION`)。
|
||||
#[path = "tests/java_refresh.rs"]
|
||||
mod java_refresh;
|
||||
@@ -0,0 +1,374 @@
|
||||
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
|
||||
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
|
||||
|
||||
use super::bridged_app;
|
||||
use crate::app::Message;
|
||||
use crate::pages;
|
||||
|
||||
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
|
||||
|
||||
/// 一条 Java 分组消息 → 页面消息。
|
||||
fn java(message: pages::settings::java::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
|
||||
///
|
||||
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
|
||||
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
|
||||
/// 一个字节都没落盘、也没有任何提示。
|
||||
///
|
||||
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
|
||||
/// JVM 回到空、`dirty` 变假)。
|
||||
#[test]
|
||||
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
|
||||
let (mut app, path) = bridged_app("java-select");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"6144".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
|
||||
"C:/jdk-21/bin/java.exe".to_string(),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
|
||||
"设为默认必须真的写进去"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.memory, "6144",
|
||||
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.jvm, "-XX:+UseG1GC",
|
||||
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.dirty,
|
||||
"内存 / JVM 参数还没保存,不许标成已保存"
|
||||
);
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r3 P3-3:首屏自动扫必须有落脚点
|
||||
|
||||
/// **首屏自动扫一次盘**这件事必须在测试里有落脚点(r3 复审 P3-3)。
|
||||
///
|
||||
/// 现场:`hydrate_java_settings` 末尾那次 `if !cfg!(test) { auto_scan_java_if_due(...) }` 整行删掉、
|
||||
/// 或把实参翻成 `true`(截图抑制档),全套测试仍是绿的——于是「设置页从不自动检测本机 Java」
|
||||
/// 这个**用户实测的头号抱怨**回来时没人拦。
|
||||
///
|
||||
/// 为什么这条不是「再把判据测一遍」:判据(`auto_scan_due` 的四条)与取消语义已经有用例;这里要
|
||||
/// 证明的是**首屏这条路真的调了它**。所以走的是生产入口(`construct.rs` 构造时与心跳都调
|
||||
/// `hydrate_java_settings`)与生产作业(真扫盘),没有 `cfg!(test)` 分叉、也没有注入假作业。
|
||||
///
|
||||
/// 判据为什么同步可判、且与线程快慢无关:`ScanSlot::start_with` 在起线程**之前**就把 `running`
|
||||
/// 置真(同一把锁里);万一扫描线程已经跑完,`finished` 会留着。所以「真的起过一次扫描」= 这两个
|
||||
/// 位里至少有一个(空槽两者都是假)——不用等线程,也不看这台机器扫到了什么。
|
||||
///
|
||||
/// 变异证明:①删掉 `self.auto_scan_java_if_due(false);` 这一行 ⇒ 两位仍是假 ⇒ 红;
|
||||
/// ②把它改成 `self.auto_scan_java_if_due(true);` ⇒ 被 `auto_scan_due` 的 `!screenshot` 挡掉 ⇒ 红。
|
||||
#[test]
|
||||
fn the_first_hydrate_starts_a_real_scan_by_itself() {
|
||||
let (mut app, path) = bridged_app("java-autoscan");
|
||||
// 构造那一刻已经自动扫过一次(同一个入口);这里要重新演一遍「首屏」,所以换一个空槽。
|
||||
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
|
||||
|
||||
app.hydrate_java_settings();
|
||||
|
||||
let report = app.settings.java.scan.report();
|
||||
assert!(
|
||||
report.running || report.finished,
|
||||
"首屏 hydrate 没有自动发起扫描(快照是空的 {report:?}):设置页就不会自己检测本机 Java\
|
||||
(r3 P3-3 的回归)"
|
||||
);
|
||||
// 收尾:取消这次真扫盘,别让它在后台继续探测本机 PATH 上的 java。
|
||||
app.settings.java.scan.cancel();
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
|
||||
|
||||
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// 一个会一直卡住的扫描作业(直到被取消)。
|
||||
fn held_job(
|
||||
_candidates: &[unlucky_core::game::java::staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<pages::settings::java::RuntimeRow> {
|
||||
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
|
||||
///
|
||||
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
|
||||
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
|
||||
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
|
||||
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
|
||||
#[test]
|
||||
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
HOLD.store(false, Ordering::SeqCst);
|
||||
let (mut app, path) = bridged_app("java-cancel");
|
||||
// 构造时已经自动扫过一次(首屏那条生产入口,见上一条用例);换成空槽,让本用例自己起。
|
||||
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
|
||||
|
||||
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
|
||||
// cancelled=true / finished=false —— 正是复审指认的那个形状。
|
||||
let slot = app.settings.java.scan.clone();
|
||||
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
|
||||
slot.cancel();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(!slot.report().running, "取消之后线程必须退出");
|
||||
assert!(slot.report().cancelled);
|
||||
assert!(!slot.report().finished);
|
||||
|
||||
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
|
||||
app.hydrate_java_scan();
|
||||
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
|
||||
assert!(app.settings.java.scanning.is_none());
|
||||
|
||||
// 心跳那条路:走**生产入口**(`clock.rs` 每发现配置文件变了就调 `hydrate_java_settings`),
|
||||
// 它末尾那次自动发起必须被「取消过」这一条挡住。
|
||||
app.hydrate_java_settings();
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销):{:?}",
|
||||
slot.report()
|
||||
);
|
||||
app.hydrate_java_scan();
|
||||
assert!(
|
||||
app.settings.java.scanning.is_none(),
|
||||
"状态行不许回到「正在扫描」"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.cancelled,
|
||||
"取消标记要保持到下一次手动扫描为止"
|
||||
);
|
||||
|
||||
HOLD.store(true, Ordering::SeqCst);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
|
||||
|
||||
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
|
||||
///
|
||||
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
|
||||
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
|
||||
/// `Not saved: IO 失败 @ …`。
|
||||
///
|
||||
/// M199 起页面侧已经撞不到这一档了(`java.extra_args` 超长先被 `State::validate` 具名拒掉,见下一条
|
||||
/// 用例),所以这里直接钉 `write_notice` 这一层的映射:`Schema` 只能变成页面自己的 key。
|
||||
///
|
||||
/// 变异证明:把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 红;把它拼进
|
||||
/// `Notice::Refused` 的 detail ⇒ 红(Debug 里会出现那句中文)。
|
||||
#[test]
|
||||
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
|
||||
use pages::settings::java::store::WriteError;
|
||||
|
||||
let notice = super::super::java::write_notice(WriteError::Schema(
|
||||
"IO 失败 @ /tmp/launcher.json".to_string(),
|
||||
));
|
||||
assert_eq!(notice, pages::settings::java::Notice::WriteFailed);
|
||||
let printed = format!("{notice:?}");
|
||||
assert!(
|
||||
!printed.contains("IO 失败"),
|
||||
"core 的中文句子漏进了界面文案:{printed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 粘了 2000 字符的 JVM 参数:保存被**具名理由**拦下,而且文件一个字节都不动。
|
||||
///
|
||||
/// 现场(r3 复审 P3-4):`java.extra_args` 的 `max_len=1024` 只在 schema 那一档,页面侧不判长度
|
||||
/// ⇒ 用户得到的是那句通用的「写不进去」,具体原因只在 stderr。本用例走的是 App 那条真保存路径。
|
||||
///
|
||||
/// 变异证明:删掉 `State::validate` 里的 `MAX_JVM_ARGS` 判据 ⇒ notice 变回
|
||||
/// `Notice::WriteFailed`(schema 那一档),本条红。
|
||||
#[test]
|
||||
fn an_overlong_jvm_line_is_refused_with_its_own_reason() {
|
||||
use pages::settings::java::Notice;
|
||||
|
||||
let (mut app, path) = bridged_app("java-jvm-long");
|
||||
let too_long = "-D".to_string() + &"x".repeat(2000);
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
let notice = app
|
||||
.settings
|
||||
.java
|
||||
.notice
|
||||
.clone()
|
||||
.expect("保存必须留下一条回执");
|
||||
let Notice::Refused(reason) = notice else {
|
||||
panic!("超长参数要给自己的原因(「太长」),不是那句通用的「写不进去」:{notice:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
reason.label(),
|
||||
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
|
||||
"拒绝原因必须落到「太长」那条文案上(中英各一句,见 i18n 金表)"
|
||||
);
|
||||
assert!(!path.exists(), "被本地校验拒掉的保存不许碰磁盘");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- M197:三个设置键 + 真下载的落点
|
||||
|
||||
/// 三键改完点保存:**真落盘**、草稿被读回值收尾、不再脏,并且保存顺手重扫一次。
|
||||
///
|
||||
/// 变异证明:`save_java_prefs` 里不调 `prefs.adopt(snapshot)` ⇒ 草稿还是 `" D:/JDKs "`、
|
||||
/// `dirty` 仍为真 ⇒ 本条红;不调 `start_java_scan` ⇒ `scan.report().running` 那条红。
|
||||
#[test]
|
||||
fn saving_the_three_keys_writes_them_and_reads_them_back() {
|
||||
use pages::settings::java::prefs_store;
|
||||
|
||||
let (mut app, path) = bridged_app("java-prefs");
|
||||
// 落点用这个临时数据根下的绝对路径:Windows / macOS / Linux 上都是绝对的,
|
||||
// 断言于是与平台无关(写 "D:/JDKs" 在非 Windows 上是相对路径,会被解析到数据根下)。
|
||||
let home = path.parent().unwrap().parent().unwrap().to_path_buf();
|
||||
let jdks = home.join("JDKs").display().to_string();
|
||||
let _ = app.update(java(pages::settings::java::Message::EditInstallRoot(
|
||||
format!(" {jdks} "),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::ToggleAutoInstall(
|
||||
false,
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::MoveVendor {
|
||||
index: 0,
|
||||
up: false,
|
||||
}));
|
||||
assert!(app.settings.java.prefs.dirty(), "改过三键就该是脏的");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SavePrefs));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.notice,
|
||||
Some(pages::settings::java::Notice::PrefsSaved)
|
||||
);
|
||||
assert!(
|
||||
!app.settings.java.prefs.dirty(),
|
||||
"保存并把读回值当草稿之后不该还脏"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.prefs.install_root, jdks,
|
||||
"草稿必须等于磁盘上那份(trim 过的),不是用户刚敲的原样"
|
||||
);
|
||||
assert!(
|
||||
!app.settings.java.prefs.saved_auto_install,
|
||||
"auto_install 要真变成关"
|
||||
);
|
||||
|
||||
// 冷读:另起一次 load,确认三个键都在文件里。
|
||||
let snapshot = prefs_store::load(&path);
|
||||
assert_eq!(snapshot.install_root, jdks);
|
||||
assert!(!snapshot.auto_install);
|
||||
assert_eq!(snapshot.priority.len(), 4, "下移之后落盘的是整条链");
|
||||
assert_ne!(snapshot.priority[0], "temurin", "顺序真的被改过");
|
||||
assert!(
|
||||
app.settings.java.scan.report().running,
|
||||
"改了落点必须重扫一次,否则列表还停在旧目录里"
|
||||
);
|
||||
// 生效值也跟着换了层级(设了 installRoot ⇒ 落点来自设置)。
|
||||
assert_eq!(
|
||||
app.settings.java.effective.root, jdks,
|
||||
"生效落点必须是刚保存的那一个"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// `java.auto_install` 开着且还没有默认 JDK:装完之后自动把新装的那份设为默认。
|
||||
///
|
||||
/// 变异证明:把 `hydrate_java_downloads` 里那段 `saved_auto_install && selected.is_empty()`
|
||||
/// 的判据删掉 ⇒ 本条红(`selected` 还是空);把关掉那一档的断言反过来说明判据不是恒真。
|
||||
#[test]
|
||||
fn auto_install_adopts_the_fresh_jdk_when_no_default_is_set() {
|
||||
use pages::settings::java::downloads::{Report, Success};
|
||||
|
||||
let (mut app, path) = bridged_app("java-adopt");
|
||||
app.settings.java.prefs.saved_auto_install = true;
|
||||
app.settings.java.selected = String::new();
|
||||
app.settings.java.downloads.seed_report(Report {
|
||||
job: None,
|
||||
tries: Vec::new(),
|
||||
failure: None,
|
||||
success: Some(Success {
|
||||
major: 21,
|
||||
vendor: "Eclipse Temurin",
|
||||
root: "D:/jdks/temurin/21".to_string(),
|
||||
binary: "D:/jdks/temurin/21/bin/java.exe".to_string(),
|
||||
source: Some("tuna".to_string()),
|
||||
reused: false,
|
||||
}),
|
||||
});
|
||||
assert!(app.hydrate_java_downloads(), "快照变了就要重建视图");
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "D:/jdks/temurin/21/bin/java.exe",
|
||||
"auto_install 开着 + 没有默认 JDK ⇒ 新装的那份自动成为默认"
|
||||
);
|
||||
let snapshot = pages::settings::java::store::load(&path);
|
||||
assert_eq!(
|
||||
snapshot.selected, "D:/jdks/temurin/21/bin/java.exe",
|
||||
"而且真写进了 java.selected_path"
|
||||
);
|
||||
|
||||
// 关掉 auto_install 时同样的成功快照**不**改默认。
|
||||
let (mut app, path) = bridged_app("java-adopt-off");
|
||||
app.settings.java.prefs.saved_auto_install = false;
|
||||
app.settings.java.selected = String::new();
|
||||
app.settings.java.downloads.seed_report(Report {
|
||||
job: None,
|
||||
tries: Vec::new(),
|
||||
failure: None,
|
||||
success: Some(Success {
|
||||
major: 17,
|
||||
vendor: "Eclipse Temurin",
|
||||
root: "D:/jdks/temurin/17".to_string(),
|
||||
binary: "D:/jdks/temurin/17/bin/java.exe".to_string(),
|
||||
source: None,
|
||||
reused: true,
|
||||
}),
|
||||
});
|
||||
assert!(app.hydrate_java_downloads());
|
||||
assert_eq!(app.settings.java.selected, "", "关掉就不许替用户改默认");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// 点一个目录里没有的大版本:立刻在下载区顶上写清原因,**不**起线程。
|
||||
///
|
||||
/// 变异证明:把 `start_java_download` 里 `catalog::find(major).is_none()` 那道判据删掉 ⇒
|
||||
/// `download_notice` 为 `None`,本条红(点了却什么都不说是禁止的)。
|
||||
#[test]
|
||||
fn an_unknown_major_is_refused_on_the_spot() {
|
||||
use pages::settings::java::downloads::Refusal;
|
||||
|
||||
let (mut app, path) = bridged_app("java-dl-refuse");
|
||||
let _ = app.update(java(pages::settings::java::Message::Download(99)));
|
||||
assert_eq!(
|
||||
app.settings.java.download_notice,
|
||||
Some(Refusal::UnknownMajor)
|
||||
);
|
||||
assert!(!app.settings.java.downloads.busy(), "不该起任何作业");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! M204:Java 组**写盘成功之后请 host 重发设置快照**这一拍(`JAVA_REFRESH_ACTION`)。
|
||||
//!
|
||||
//! ## 为什么需要这一条(缺口是什么)
|
||||
//!
|
||||
//! Java 组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
|
||||
//! 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`)——
|
||||
//! 那是**同步、原子**的一次落盘,但**不经过** `AppCommand::SetSetting` 的写盘分支。而
|
||||
//! `CoreEvent::SettingsChanged` 全仓只有两处发出:`host/dispatch.rs` 的 `SetSetting` 分支与
|
||||
//! `host/mod.rs` 的 `refresh_all`。于是三键写成功之后,`AppState::settings_groups` 里 java 组那几行
|
||||
//! 会**停在旧值**,直到下一次任何别的写入(改并发、改主题……)顺手带一份新快照回来。
|
||||
//!
|
||||
//! M204 的修法:落盘成功之后派发一条动作键(`dto::JAVA_REFRESH_ACTION`,`host::settings::set`
|
||||
//! 见到它只返回 `Ok(())`,不写盘),让 `SetSetting` 分支照常重发 `SettingsChanged`。**没有**改用
|
||||
//! `RefreshAll`:那个还会 `versions::spawn_list` 与 `start_update_check`,每点一次保存打两个网络请求。
|
||||
//!
|
||||
//! ## 两条用例证明的事不同
|
||||
//!
|
||||
//! | 用例 | 后端 | 证明 |
|
||||
//! |---|---|---|
|
||||
//! | 第一条 | **真 `CoreHost` + 临时数据根** | 写盘 ⇒ 快照真的变了(磁盘 + 快照两头的字节级证据) |
|
||||
//! | 第二条 | 假后端(记账)+ 本地校验拒绝 | **被拒的保存不许请求刷新**(那一拍只该跟在真落盘之后) |
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{find_row, AppCommand, AppEvent, Backend, SettingGroupView};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 本组要断言的键(与 presenter 快照 / schema 同名)。
|
||||
const JVM: &str = "java.extra_args";
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 一条 Java 分组消息 → 页面消息(与 `tests/java.rs` 同一个漏斗)。
|
||||
fn java(message: pages::settings::java::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// 快照里某个键的**上屏文本**(`None` = 快照里没这一行)。
|
||||
///
|
||||
/// 用 `value_text` 而不是控件序号:Java 组这三个键里 `extra_args` / `selected_path` 是 text 行,
|
||||
/// `value_text` 就是文件里的原文;内存那一行是四档 choice(见报告「没做/没验」第 ③ 条),
|
||||
/// 它的**当前值**表达在 `options[selected]` 上,所以本文件不对内存行做取值断言——那会把
|
||||
/// 「presenter 的四档表达不了 6144」这件事伪装成绿。
|
||||
fn row_value(groups: &[SettingGroupView], key: &str) -> Option<String> {
|
||||
find_row(groups, key).map(|row| row.value_text.clone())
|
||||
}
|
||||
|
||||
/// **主用例**:改内存 + JVM 参数 ⇒ 点保存 ⇒ 磁盘变了 **且** host 发回来的快照也变了。
|
||||
///
|
||||
/// 链子全长(与本组的产品路径逐段对应):`Message::EditMemory/EditJvm` → `State` 草稿 →
|
||||
/// `Message::Save` → `store::save`(一次 `store.save()` 写两个键)→ `request_settings_refresh()`
|
||||
/// → 真 `CoreHost` 的 `SetSetting` 分支(`settings::set` 认动作键、不写盘)→
|
||||
/// `CoreEvent::SettingsChanged(settings::groups(env))` → `AppState::settings_groups` →
|
||||
/// `pages::Model::settings`。断言落在**磁盘字节**与**快照文本**上,不落在任何中间变量上。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// 1. 摘掉 `request_settings_refresh()` 的 dispatch(`save_java_settings` 里那一行)⇒ 快照仍是
|
||||
/// 空串 ⇒ 红(**这就是本用例的变异验证**,且是本文件第二条都不红的唯一原因);
|
||||
/// 2. 写盘没真发生(`store::save` 的 `save()` 被删)⇒ 磁盘断言红;
|
||||
/// 3. 动作键在 presenter 侧被当成真键(`host::settings::set` 少了那条早退)⇒ `store.set` 报
|
||||
/// 「未知键」⇒ 页面拿到 error Toast、快照不更新 ⇒ 红;
|
||||
/// 4. 快照回流被丢掉(`view::model` 的 `settings:` 那行被删)⇒ `app.view.settings` 空 ⇒ 红。
|
||||
///
|
||||
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑);「页面上的值是不是旧值」这一半——写盘成功后
|
||||
/// 页面本来就该显示新值,旧值那一半由 `tests/java.rs` 的拒绝用例守(本地校验不过时文件一个字节
|
||||
/// 不动、回执是具名原因)。
|
||||
#[test]
|
||||
fn saving_the_java_form_refreshes_the_host_settings_snapshot() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m204-java-refresh-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
|
||||
let config = dir.join(".ULL/config/launcher.json");
|
||||
|
||||
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
|
||||
let mut app = App::with_presenter(
|
||||
Options {
|
||||
data_dir: Some(dir.clone()),
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(unlucky_presenter::host::CoreHost::new(env)),
|
||||
// 设置桥也指向同一个根:不碰用户的真配置。
|
||||
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
|
||||
);
|
||||
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
|
||||
app.dispatch(AppCommand::RefreshAll);
|
||||
assert_eq!(
|
||||
row_value(app.settings_snapshot(), JVM).as_deref(),
|
||||
Some(""),
|
||||
"临时根里没有 `java.extra_args` ⇒ 快照这一行是空串(出厂值就是没有额外参数)"
|
||||
);
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"6144".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
|
||||
assert!(
|
||||
written.contains("\"default_memory_mb\": 6144"),
|
||||
"磁盘上必须真的有 6144:{written}"
|
||||
);
|
||||
assert!(
|
||||
written.contains("-XX:+UseG1GC"),
|
||||
"磁盘上必须真的有那段 JVM 参数:{written}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
row_value(app.settings_snapshot(), JVM).as_deref(),
|
||||
Some("-XX:+UseG1GC"),
|
||||
"host 必须为刚写进去的三键重发一次快照(否则 AppState 里这一行还是旧值)"
|
||||
);
|
||||
assert_eq!(
|
||||
row_value(&app.view.settings, JVM).as_deref(),
|
||||
Some("-XX:+UseG1GC"),
|
||||
"页面模型也得跟着换:下一帧画的是 `Model.settings`"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 被拒的保存不许请求刷新
|
||||
|
||||
/// 只记账、不回事件的后端(本用例只关心「有没有发出那条动作键」)。
|
||||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.0.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地校验把保存拦下来时,**一次刷新都不许请求**:那一拍只该跟在真落盘之后。
|
||||
///
|
||||
/// 抓得住:把 `request_settings_refresh()` 挪到 `validate()` 之前(或无条件调用)⇒ 这里能看见
|
||||
/// 动作键 ⇒ 红。抓不住:写盘失败的回执文案(那是 `tests/java.rs` 的 `write_notice` 用例)。
|
||||
#[test]
|
||||
fn a_refused_save_does_not_ask_for_a_refresh() {
|
||||
use pages::settings::java::Notice;
|
||||
|
||||
let commands = Rc::new(RefCell::new(Vec::new()));
|
||||
let dir = std::env::temp_dir().join(format!("ull-m204-java-refuse-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&commands))),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
// 128 MB 低于 schema 的下限(512):本地校验就该拦下,不写盘。
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"128".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
// 越界的内存要在本地就被拒(具名原因 = schema 下限 512 那条文案,不是通用的「写不进去」)。
|
||||
let notice = app
|
||||
.settings
|
||||
.java
|
||||
.notice
|
||||
.clone()
|
||||
.expect("被拒的保存必须留下一条回执");
|
||||
let Notice::Refused(reason) = notice else {
|
||||
panic!("越界的内存必须是具名的本地拒绝:{notice:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
reason.label(),
|
||||
unlucky_i18n::Key::SettingsJavaReasonMemoryRange,
|
||||
"拒绝原因要落到「超出允许范围」那条文案上"
|
||||
);
|
||||
assert!(!path.exists(), "被拒的保存不许碰磁盘");
|
||||
assert!(
|
||||
!commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, .. }
|
||||
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
|
||||
)),
|
||||
"没落盘就没有可刷新的事实(实际下发:{:?})",
|
||||
commands.borrow()
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 真的落盘时,动作键**必须**发出去(正面那一半;与第一条互为背书)。
|
||||
///
|
||||
/// 为什么还要这一条:第一条用真 `CoreHost`,它的失败可能是「快照没回来」的任意一环;这一条把
|
||||
/// 「到底有没有派出那条动作键」单独钉住,红的行号不同,排查时不必再猜是哪一环。
|
||||
///
|
||||
/// 抓得住:`request_settings_refresh()` 的键名写错(比如漏了 `action` 段)⇒ presenter 会把它当
|
||||
/// 真键报「未知键」,而这条用例在本层就能看见键名对不对。
|
||||
/// 抓不住:presenter 收到动作键之后有没有真的重发快照(那是第一条的事)。
|
||||
#[test]
|
||||
fn a_successful_save_dispatches_the_refresh_action_key() {
|
||||
let commands = Rc::new(RefCell::new(Vec::new()));
|
||||
let dir = std::env::temp_dir().join(format!("ull-m204-java-dispatch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&commands))),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
assert!(path.exists(), "这一条的前提是保存真的落盘了(临时根可写)");
|
||||
assert!(
|
||||
commands.borrow().iter().any(|command| matches!(
|
||||
command,
|
||||
AppCommand::SetSetting { key, .. }
|
||||
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
|
||||
)),
|
||||
"落盘之后必须派发动作键(实际下发:{:?})",
|
||||
commands.borrow()
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 「立即扫描」真的接线
|
||||
|
||||
/// 「立即扫描」在 App 层是**真接线**的:`Message::Scan` ⇒ 起一次真扫盘(不是只画了颗按钮)。
|
||||
///
|
||||
/// 为什么这一条住在本文件(而不是 `tests/java.rs`):那个文件 374 行、`tests.rs` 正好 400 行,
|
||||
/// 再开一个模块文件就要往 `tests.rs` 加一行 `mod`(400 → 401,撞 `gate` 的 400 行红线)。
|
||||
/// 主题上也勉强算同一族:两者都是「设置页那一组对 App 做了什么」的守卫(这条守扫描入口,
|
||||
/// 上面几条守写回闭环)。
|
||||
///
|
||||
/// 抓得住:`on_settings_java` 的 `Message::Scan => return self.start_java_scan()` 被改成
|
||||
/// `Task::none()`(只画按钮不接线 —— 用户在早期投诉里点名的那一条)⇒ `report().running`
|
||||
/// 仍是 `false`、`scanning` 仍是 `None` ⇒ 红(实测见报告「变异验证」M3)。
|
||||
/// 抓不住:扫描**结论**是否写对了(那是 `pages/settings/java/tests/runtime.rs` 的事),
|
||||
/// 也抓不住真实鼠标点击(本用例从 `Message` 起跑)。
|
||||
#[test]
|
||||
fn pressing_scan_starts_a_real_scan() {
|
||||
// 用截图档构造(`Options.screenshot = Some(..)`)**只为一个目的**:让首屏那次
|
||||
// `auto_scan_java_if_due` 不发生(`hydrate_java_settings` 的截图分支灌种子后直接 return),
|
||||
// 于是「开局没有扫描在跑」这个前提是确定的。手动点「立即扫描」走的是**同一条产品代码**
|
||||
// (`start_java_scan` → `ScanSlot::start`),截图档不拦它。
|
||||
let dir = std::env::temp_dir().join(format!("ull-m204-java-scan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let mut app = App::with_presenter(
|
||||
Options {
|
||||
screenshot: Some(std::path::PathBuf::from("m204-scan.png")),
|
||||
..Options::default()
|
||||
},
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
assert!(
|
||||
!app.settings.java.scan.report().running,
|
||||
"这一条的前提:构造时没有扫描在跑"
|
||||
);
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::Scan));
|
||||
|
||||
let report = app.settings.java.scan.report();
|
||||
assert!(
|
||||
report.running,
|
||||
"「立即扫描」必须真的起一条扫描线程:{report:?}"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.scanning.is_some(),
|
||||
"状态行要能区分「正在扫描」(`scanning` 不能还是 None)"
|
||||
);
|
||||
|
||||
// 别把这条用例起的真扫盘留给后面的用例:置取消位,线程会在下一个候选边界退出。
|
||||
app.settings.java.scan.cancel();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! 自更新动作的落点(M159):`pages::Message::Update(UpdateAction)` → presenter 指令 / 重启。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不做乐观回显**:点了「下载并安装」不改本地状态,等 `CoreEvent::Update` 回来才变
|
||||
//! (与启动/安装同一条:本地状态与事件流打架时,界面会自说一套);
|
||||
//! 2. 更新的**判断**(要不要提示、失败算什么、版本比较)全在 core/presenter,这里只转发;
|
||||
//! 3. 「立即重启」是本文件唯一碰进程的地方:先按同样的命令行拉起新进程,再退出自己——
|
||||
//! 正因为替换已经完成(新 exe 就在原路径上),拉起来的就是新版本。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
use crate::pages::UpdateAction;
|
||||
|
||||
use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 一条更新动作。
|
||||
pub(super) fn on_update_action(&mut self, action: UpdateAction) -> Task<Message> {
|
||||
match action {
|
||||
UpdateAction::CheckNow => {
|
||||
if self.update_busy() {
|
||||
return self.refuse_busy("立即检查");
|
||||
}
|
||||
self.dispatch(AppCommand::CheckUpdate)
|
||||
}
|
||||
UpdateAction::Apply => {
|
||||
if self.update_busy() {
|
||||
return self.refuse_busy("下载并安装");
|
||||
}
|
||||
self.dispatch(AppCommand::ApplyUpdate)
|
||||
}
|
||||
UpdateAction::Dismiss => self.dispatch(AppCommand::DismissUpdate),
|
||||
UpdateAction::SetAutoCheck(on) => self.dispatch(AppCommand::SetUpdateAutoCheck(on)),
|
||||
UpdateAction::RestartNow => return self.restart_now(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 有没有一条检查/下载正在跑。
|
||||
///
|
||||
/// 与 `pages::Update::busy()` **同一个判据**(`applying` 或 `status == Checking`):
|
||||
/// presenter 的 `begin_check` / `begin_apply` 在 inflight 时静默 `return None`,
|
||||
/// 所以「忙碌」这件事必须在按下之前就挡住——否则用户点了没反应,还以为按钮坏了(M161)。
|
||||
///
|
||||
/// 这一层是**最后一道**:按钮已经画成禁用(`pages/settings/update.rs` 的 `can_check` /
|
||||
/// `can_apply`),但快捷键/未来新增的入口仍会走到这里。
|
||||
fn update_busy(&self) -> bool {
|
||||
let view = &self.state.update;
|
||||
view.applying || view.outcome.is_checking()
|
||||
}
|
||||
|
||||
/// 忙碌时把这条动作丢掉(不派发、不乐观改状态),并在日志里留一行取证。
|
||||
fn refuse_busy(&self, what: &str) -> Task<Message> {
|
||||
diag!("[update] 已有一条检查/下载在跑,忽略重复的「{what}」");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 立即重启:按同样的命令行拉起自己,然后退出。
|
||||
///
|
||||
/// 截图通道(`--screenshot`)**不重启**:那条路每跑一次就该落一张图然后退出,重启会覆盖同一
|
||||
/// 文件、并把取证进程拖成死循环。
|
||||
fn restart_now(&mut self) -> Task<Message> {
|
||||
if self.options.screenshot.is_some() {
|
||||
diag!("[update] 截图通道不重启(忽略「立即重启」)");
|
||||
return Task::none();
|
||||
}
|
||||
match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
// 子进程是本 GUI exe 自己(Subsystem=2,不会分到控制台);走 `quiet` 是为了让
|
||||
// 「app-iced 里除 opener 外不留裸构造」这条规则整体可被守卫钉住(M217)。
|
||||
let spawned = unlucky_core::platform::child::quiet(&exe)
|
||||
.args(&args)
|
||||
.spawn();
|
||||
match spawned {
|
||||
Ok(child) => diag!(
|
||||
"[update] 已拉起新进程 pid={}:{}",
|
||||
child.id(),
|
||||
exe.display()
|
||||
),
|
||||
Err(error) => {
|
||||
diag!("[update] 拉起新进程失败:{error}(请手动重启启动器)");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
diag!("[update] 取 current_exe 失败:{error}");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
iced::exit()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages::UpdateAction;
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 记录命令流的假后端:把 `dispatch` 收到的每一条抄进共享表(动作接线断没断靠它判)。
|
||||
struct Recorder {
|
||||
seen: Arc<Mutex<Vec<AppCommand>>>,
|
||||
}
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.seen
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.push(command);
|
||||
}
|
||||
|
||||
fn tick(&mut self, _elapsed: std::time::Duration) {}
|
||||
}
|
||||
|
||||
fn app() -> (App, Arc<Mutex<Vec<AppCommand>>>) {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder {
|
||||
seen: Arc::clone(&seen),
|
||||
}),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
(app, seen)
|
||||
}
|
||||
|
||||
fn last(seen: &Arc<Mutex<Vec<AppCommand>>>) -> AppCommand {
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("应当派发过一条命令")
|
||||
}
|
||||
|
||||
/// 四个转发动作各自交给后端**对应的**指令(串线会红)。
|
||||
#[test]
|
||||
fn every_update_action_reaches_the_backend() {
|
||||
let (mut app, seen) = app();
|
||||
|
||||
let _: Task<_> = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(last(&seen), AppCommand::CheckUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
assert_eq!(last(&seen), AppCommand::ApplyUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::Dismiss);
|
||||
assert_eq!(last(&seen), AppCommand::DismissUpdate);
|
||||
|
||||
let _ = app.on_update_action(UpdateAction::SetAutoCheck(false));
|
||||
assert_eq!(last(&seen), AppCommand::SetUpdateAutoCheck(false));
|
||||
}
|
||||
|
||||
/// **忙碌时按下去不发命令**(M161:此前点了没反应,因为 presenter 在 inflight 时静默返回)。
|
||||
///
|
||||
/// 三条都要钉住:①`Checking` 时「立即检查」不派发;②`applying` 时「下载并安装」不派发;
|
||||
/// ③忙碌结束后**恢回可点**(失败/超时不该把按钮永久钉死)。
|
||||
#[test]
|
||||
fn pressing_while_busy_dispatches_nothing() {
|
||||
let (mut app, seen) = app();
|
||||
|
||||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Checking;
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert!(
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_empty(),
|
||||
"正在检查时再按「立即检查」不许派发第二条命令"
|
||||
);
|
||||
|
||||
// 检查回来(失败也算回来):不忙了,能再点。
|
||||
app.state.update.outcome = unlucky_presenter::dto::UpdateOutcome::Failed(
|
||||
unlucky_presenter::dto::UpdateFailureView::new("network", "连不上", true),
|
||||
);
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(last(&seen), AppCommand::CheckUpdate, "失败之后要能重试");
|
||||
|
||||
// 下载/校验/替换期间:`Apply` 与 `CheckNow` 都不许派发。
|
||||
let before = seen.lock().unwrap_or_else(|p| p.into_inner()).len();
|
||||
app.state.update.applying = true;
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
let _ = app.on_update_action(UpdateAction::CheckNow);
|
||||
assert_eq!(
|
||||
seen.lock().unwrap_or_else(|p| p.into_inner()).len(),
|
||||
before,
|
||||
"applying 期间不许派发任何新的检查/安装"
|
||||
);
|
||||
|
||||
// 应用结束:恢复可点。
|
||||
app.state.update.applying = false;
|
||||
let _ = app.on_update_action(UpdateAction::Apply);
|
||||
assert_eq!(last(&seen), AppCommand::ApplyUpdate, "失败/完成后恢回可点");
|
||||
}
|
||||
|
||||
/// 截图通道不重启:取证进程不许把自己重启成死循环。
|
||||
#[test]
|
||||
fn the_screenshot_channel_never_restarts() {
|
||||
let (mut app, seen) = app();
|
||||
app.options.screenshot = Some(std::path::PathBuf::from("C:/Temp/shot.png"));
|
||||
let _ = app.on_update_action(UpdateAction::RestartNow);
|
||||
assert!(
|
||||
seen.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_empty(),
|
||||
"重启不是一条后端指令(不该派发任何命令)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
|
||||
//!
|
||||
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
|
||||
//!
|
||||
//! 实例页那一段(`1.20.4 · Fabric`、运行状态、元信息卡)M120 搬进 [`instances`]:本文件
|
||||
//! 当时贴着 400 行硬线,而 i18n 还要在这个函数里接一批阶段标签。
|
||||
|
||||
use iced::{Element, Theme};
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Screen;
|
||||
use crate::pages::{self, Row};
|
||||
use crate::shell::{self, agent, NavItem};
|
||||
|
||||
use super::message::{nav_item_of, Message};
|
||||
use super::state::{log_first_frame, App};
|
||||
use super::APP_TITLE;
|
||||
|
||||
mod account_page;
|
||||
pub(super) mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
mod update;
|
||||
|
||||
use instances::{detail_of, runtime_status_row, supporting_line};
|
||||
use logs::logs_model;
|
||||
use update::update_of;
|
||||
|
||||
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
|
||||
///
|
||||
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
|
||||
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`),
|
||||
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
|
||||
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// D30 §2.1 的「哪一行日志证明它是哪个组/主题」:出图通道专用的一行身份日志。
|
||||
///
|
||||
/// ## 为什么必须有
|
||||
///
|
||||
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
|
||||
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
|
||||
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。
|
||||
///
|
||||
/// ## 为什么只在变化时打
|
||||
///
|
||||
/// `App::view` 每帧都调用;每帧一行会把 `launcher.log` 刷满,反而没人能从中找到那一行。
|
||||
/// 判据是(组序号,主题名)这一对——同一次截图进程里最多两行(首帧 + 主题切一次)。
|
||||
///
|
||||
/// ## 为什么是 `try_lock` 而不是 `lock`
|
||||
///
|
||||
/// 这里在 UI 线程的每帧路径上。`try_lock` 拿不到锁就**直接返回**(这一帧不打,下一帧还会再来,
|
||||
/// 身份行只是晚一拍、不会丢),于是「界面帧永远不会被这把锁挡住」是**代码保证的**,不是靠
|
||||
/// 「今天只有一个调用点、锁里只有一次比较」这种会随改动失效的前提。M204 r1 复审把原先那句
|
||||
/// 「拿不到锁就不打」和当时用的阻塞 `lock()` 对上了:注释承诺的保证必须由代码提供。
|
||||
///
|
||||
/// **非截图通道一律不打**:交互模式下用户每点一次左列就多一行日志,没有价值。
|
||||
fn note_screenshot_group(screenshot: bool, route: &Route, group: usize, theme: &str) {
|
||||
if !screenshot {
|
||||
return;
|
||||
}
|
||||
let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else {
|
||||
return;
|
||||
};
|
||||
let seen = (group, theme.to_string());
|
||||
if last.as_ref() == Some(&seen) {
|
||||
return;
|
||||
}
|
||||
*last = Some(seen);
|
||||
drop(last);
|
||||
crate::app::diag::diag!("[shot] settings route={route:?} group={group} theme={theme}");
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
|
||||
pub(super) fn theme(&self) -> Theme {
|
||||
self.ui().theme()
|
||||
}
|
||||
|
||||
/// 窗口标题(自绘标题栏另画一份,这里是给系统/截图工具看的)。
|
||||
pub(super) fn title(&self) -> String {
|
||||
APP_TITLE.to_owned()
|
||||
}
|
||||
|
||||
/// 整棵树。
|
||||
pub(super) fn view(&self) -> Element<'_, Message> {
|
||||
log_first_frame();
|
||||
|
||||
// D30 §2.1:出图通道要有一行能**自证身份**的日志——「这张图是哪个设置组、哪个主题」。
|
||||
// 上一次 M201 的事故就是拿外观页当下载组的证据;图不自证身份,结论就无处落地。
|
||||
// 只对设置页打(左列那几组才有「哪个组」这件事)。
|
||||
if self.state.route() == &Route::Settings {
|
||||
note_screenshot_group(
|
||||
self.options.screenshot.is_some(),
|
||||
self.state.route(),
|
||||
self.appearance.selected_group,
|
||||
self.ui().name(),
|
||||
);
|
||||
}
|
||||
|
||||
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
|
||||
// 这里只做一次 map,组件库与业务 Message 依然解耦。
|
||||
if self.options.screen == Screen::Gallery {
|
||||
return super::components::gallery::view(&self.ui).map(Message::Demo);
|
||||
}
|
||||
|
||||
// 产品页走**内容槽位**:外壳(标题栏 / 拖动与缩放热区 / 三个 caption 键 / rail)由
|
||||
// `shell` 画,中间那块才是页面(`DragWindow`/`ResizeStart`/`Minimize`/`ToggleMaximize`/`Close`
|
||||
// 五种消息的构造点只在外壳里,页面树自己画的标题栏拖不动窗口)。
|
||||
//
|
||||
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
|
||||
// 的那份状态。M101 r2 起 ③ 走 `page_with_settings`:外观状态 + 各组自己的状态一起递进去。
|
||||
let background =
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
// 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。
|
||||
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
// M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层
|
||||
// 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。
|
||||
// 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG`
|
||||
let agent_catalog = self.lang().catalog();
|
||||
// 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实
|
||||
let session = agent::Session {
|
||||
agent: &self.agent,
|
||||
rail: self.agent_host.rail(),
|
||||
failure: self.agent_failure(),
|
||||
simulated: self.agent_host.is_simulated(),
|
||||
};
|
||||
|
||||
let account = self.rail_account();
|
||||
// M187:页面树先立起来,再叠模态层(弹窗画在整窗之上,含标题栏区域)。
|
||||
let tree: Element<'_, Message> = match pages::Screen::of(self.state.route()) {
|
||||
Some(_) => shell::view_with_account(
|
||||
&self.ui,
|
||||
shell::NavRail {
|
||||
selected: nav_item_of(self.state.route()),
|
||||
account: account.clone(),
|
||||
},
|
||||
APP_TITLE,
|
||||
background,
|
||||
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
||||
pages::page_with_settings(
|
||||
&self.ui,
|
||||
&self.view,
|
||||
pages::settings::States {
|
||||
appearance: Some(&self.appearance),
|
||||
groups: &self.settings,
|
||||
// M201:下载组画真控件要的那份快照(`Model` 里那份,`refresh_view` 刚重建)。
|
||||
settings: &self.view.settings,
|
||||
},
|
||||
// M106:设置页 General 组要画「当前档位 + 生效语言」。
|
||||
self.language(),
|
||||
// M157:实例页「启动参数」区的表单(编辑态归 `App`,页面只借一帧)。
|
||||
&self.launch_form,
|
||||
)
|
||||
.map(Message::Page),
|
||||
agent::docked(&self.ui, session, agent_catalog, self.width)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
),
|
||||
// 账户页(M114):它不走 `pages::Screen` 那四页的 `Model`,自己一棵树
|
||||
// (树在 `app/view/account_page.rs`,与其它页共用 M104 的中栏可用宽)。
|
||||
None if self.state.route() == &Route::Accounts => {
|
||||
self.account_page(&account, background, overlay)
|
||||
}
|
||||
// `Route::Agent`:独立页与右栏共用同一个 `conversation::view`(`agent::page` 内部调的它)。
|
||||
None => shell::view_with_account(
|
||||
&self.ui,
|
||||
shell::NavRail {
|
||||
selected: NavItem::Ai,
|
||||
account: account.clone(),
|
||||
},
|
||||
APP_TITLE,
|
||||
background,
|
||||
agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent),
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
),
|
||||
};
|
||||
// 队列里有一条就叠一层模态(没有则原样返回)。模型由 `refresh_view` 维护。
|
||||
match self.dialog_model.as_ref() {
|
||||
Some(modal) => super::dialog::view::layer(&self.ui, tree, modal),
|
||||
None => tree,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 视图模型
|
||||
|
||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
///
|
||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||
///
|
||||
/// `width` 是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝,由 `AgentUi::content_width` 算),不是窗口宽:
|
||||
/// 页面布局系统仍按「一个窗口宽」扣左轨与页边距,所以右栗占掉的那一段必须在这里先减掉——
|
||||
/// 否则助手栏一展开,页面还按 1180 排版,内容就顶到面板底下了(任务里的「收缩而不是溢出」)。
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
height: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
draft: &pages::InstallDraft,
|
||||
lang: Lang,
|
||||
content: &pages::content::PageState,
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
// 实例页的文案真源:`App::lang` 的目录(M120)。列表 overline / 元信息标签 / 空态都从它取。
|
||||
let catalog = lang.catalog();
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.tiles()
|
||||
.into_iter()
|
||||
.map(|cell| Row {
|
||||
id: cell.instance.id.clone(),
|
||||
name: cell.instance.name.clone(),
|
||||
supporting: supporting_line(cell.instance, catalog),
|
||||
status: runtime_status_row(&cell.instance.runtime, catalog),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let detail = match &route {
|
||||
Route::Detail(id) => state
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
.map(|instance| detail_of(instance, catalog)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
pages::Model {
|
||||
screen,
|
||||
window_width: width,
|
||||
window_height: height,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||
// schema 键取当前值画控件;写回后 host 重新发 `SettingsChanged` ⇒ 下一次重建换新值。
|
||||
settings: state.settings_groups.clone(),
|
||||
lang,
|
||||
forced: forced.map(|state| match state {
|
||||
// `Rest` = 常态(压掉系统光标 hover);`Interaction::Enabled` 就是静息态,不叠状态层。
|
||||
crate::cli::ForcedState::Rest => Interaction::Enabled,
|
||||
crate::cli::ForcedState::Hover => Interaction::Hovered,
|
||||
crate::cli::ForcedState::Pressed => Interaction::Pressed,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// 测试只借这一条(其余三条 `model()` 自己用)。
|
||||
#[cfg(test)]
|
||||
use instances::last_played;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 账户页(M114)的**整页装配**:从 `App` 的状态拼出内容树,再交给外壳。
|
||||
//!
|
||||
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线(M104 的右栏分支已经把余量吃掉了),
|
||||
//! 而这段是纯装配(没有状态数学),搬出来不改变任何语义。
|
||||
//!
|
||||
//! 宽度口径:用 `AccountPage::width`(= M104 的 `AgentUi::content_width`,中栏可用宽),
|
||||
//! 与其它页、与 `refresh_view` 同一份来源——右栏展开时账户页**一起收缩**,不会顶到面板底下。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::pages;
|
||||
use crate::pages::account::Page as AccountPage;
|
||||
use crate::shell::{self, agent, NavItem, RailAccount};
|
||||
|
||||
use super::super::state::App;
|
||||
use super::super::APP_TITLE;
|
||||
|
||||
impl App {
|
||||
/// 账户页视图模型的重建(`refresh_view` 的账户那一步)。
|
||||
///
|
||||
/// 与 `view` 同一次门控;语言跟着 M106 的 `LangState` 走(M114 早期钉死 `Lang::En`
|
||||
/// 的写法已删),宽度用 M104 的中栏可用宽。放在这里是为了让 `app/state.rs` 守住 400 行。
|
||||
pub(in crate::app) fn rebuild_account_page(&mut self, width: f32) {
|
||||
let lang = self.lang();
|
||||
self.account = AccountPage::build(
|
||||
&self.state,
|
||||
&self.account_form,
|
||||
&mut self.avatars,
|
||||
lang,
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
/// 账户页(走 `pages::account` 自己的树,而不是 `pages::Screen` 那四页的 `Model`)。
|
||||
pub(super) fn account_page<'a>(
|
||||
&'a self,
|
||||
account: &RailAccount,
|
||||
background: Option<Element<'a, Message>>,
|
||||
overlay: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics =
|
||||
pages::layout::frame_metrics(pages::layout::PaneKit::Single, self.account.width);
|
||||
shell::view_with_account(
|
||||
&self.ui,
|
||||
shell::NavRail {
|
||||
selected: NavItem::Account,
|
||||
account: account.clone(),
|
||||
},
|
||||
APP_TITLE,
|
||||
background,
|
||||
// M106:账户页的文案跟着**生效语言**走(`LangState::boot` 自己读 `ULL_LANG`,
|
||||
// 所以 M114 早期那个「截图专用语言开关」已删——两套开关必然漂)。
|
||||
pages::account::view(&self.ui, metrics, &self.account, self.lang()).map(Message::Page),
|
||||
// M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`)
|
||||
agent::docked(
|
||||
&self.ui,
|
||||
agent::Session {
|
||||
agent: &self.agent,
|
||||
rail: self.agent_host.rail(),
|
||||
failure: self.agent_failure(),
|
||||
simulated: self.agent_host.is_simulated(),
|
||||
},
|
||||
self.lang().catalog(),
|
||||
self.width,
|
||||
)
|
||||
.map(|element| element.map(Message::Agent)),
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
self.lang(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! 实例页的**展示转换**(M120 从 `app/view.rs` 原样搬出:那边给 i18n 让出行数)。
|
||||
//!
|
||||
//! | 函数 | 产物 | 谁读 |
|
||||
//! |---|---|---|
|
||||
//! | [`supporting_line`] | 列表行 / 详情副标题 `1.20.4 · Fabric 0.16.5` | [`crate::pages::Row`] / [`crate::pages::Detail`] |
|
||||
//! | [`runtime_status`] | 运行状态行(列表 overline + 详情状态行共用一份) | 同上 |
|
||||
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
|
||||
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
|
||||
//!
|
||||
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
|
||||
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
|
||||
//!
|
||||
//! ## 两条接法上的取舍
|
||||
//!
|
||||
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
|
||||
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
|
||||
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
|
||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
format!(
|
||||
"{} · {}",
|
||||
instance.mc_version,
|
||||
loader_badge(instance, catalog)
|
||||
)
|
||||
}
|
||||
|
||||
/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。
|
||||
///
|
||||
/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒
|
||||
/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()`
|
||||
/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」——
|
||||
/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。
|
||||
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
match instance.loader.kind {
|
||||
// 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」
|
||||
LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(),
|
||||
// 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名
|
||||
_ => instance.loader.badge(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
|
||||
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
|
||||
/// 未来新增的值不许在这里被猜成一个意思)。
|
||||
fn isolation(raw: &str, catalog: Catalog) -> String {
|
||||
match raw {
|
||||
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
|
||||
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => None,
|
||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||
InstanceRuntime::Busy { stage, progress } => Some(format!(
|
||||
"{} · {:.0}%",
|
||||
stage_label(*stage, catalog),
|
||||
progress.ratio() * 100.0
|
||||
)),
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||||
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
|
||||
// 绝不说成「启动游戏进程失败」。
|
||||
//
|
||||
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||||
// (单行、不许折行,见 D-M79-5)。
|
||||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条失败状态行。
|
||||
///
|
||||
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
|
||||
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
|
||||
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
|
||||
match cause {
|
||||
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
|
||||
Key::LaunchStageFailed,
|
||||
&[
|
||||
("stage", failure_stage_label(*stage, catalog)),
|
||||
("detail", detail.as_str()),
|
||||
],
|
||||
),
|
||||
LaunchFailureCause::Stage { stage, .. } => catalog.render(
|
||||
Key::InstancesFailed,
|
||||
&[("reason", failure_stage_label(*stage, catalog))],
|
||||
),
|
||||
LaunchFailureCause::Crashed { exit_code } => {
|
||||
let reason = match exit_code {
|
||||
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
|
||||
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
|
||||
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
|
||||
};
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||||
}
|
||||
LaunchFailureCause::SilentFailure { .. } => {
|
||||
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
|
||||
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
|
||||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
|
||||
///
|
||||
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
|
||||
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
|
||||
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
|
||||
other => runtime_status(other, catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
|
||||
///
|
||||
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
|
||||
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
|
||||
catalog.text(match stage {
|
||||
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
|
||||
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
|
||||
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
|
||||
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
|
||||
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
|
||||
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
|
||||
///
|
||||
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
|
||||
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
|
||||
#[must_use]
|
||||
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
|
||||
let catalog = lang.catalog();
|
||||
if let Some(failure) = &finished.failure {
|
||||
return catalog.render(
|
||||
Key::LaunchStageFailed,
|
||||
&[
|
||||
("stage", failure_stage_label(failure.stage, catalog)),
|
||||
("detail", failure.detail.as_str()),
|
||||
],
|
||||
);
|
||||
}
|
||||
let outcome = catalog.text(match finished.outcome {
|
||||
LogOutcome::Ok => Key::LaunchOutcomeNormal,
|
||||
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
|
||||
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
|
||||
});
|
||||
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
|
||||
catalog.render(
|
||||
Key::LaunchToastEnded,
|
||||
&[("outcome", outcome), ("seconds", seconds.as_str())],
|
||||
)
|
||||
}
|
||||
|
||||
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||||
catalog.text(match stage {
|
||||
LaunchStage::Download => Key::InstancesStageDownload,
|
||||
LaunchStage::Verify => Key::InstancesStageVerify,
|
||||
LaunchStage::Launch => Key::InstancesStageLaunch,
|
||||
LaunchStage::Window => Key::InstancesStageWindow,
|
||||
})
|
||||
}
|
||||
|
||||
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
|
||||
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
|
||||
Detail {
|
||||
id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
subtitle: supporting_line(instance, catalog),
|
||||
meta: vec![
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaVersion),
|
||||
&instance.mc_version,
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaLoader),
|
||||
&loader_badge(instance, catalog),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaIsolation),
|
||||
&isolation(&instance.isolation, catalog),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaVersionId),
|
||||
&instance.version_id,
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaFiles),
|
||||
catalog.text(if instance.version_present {
|
||||
Key::InstancesPresentOnDisk
|
||||
} else {
|
||||
Key::InstancesMissing
|
||||
}),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaLastPlayed),
|
||||
&last_played(instance.last_played.as_deref(), catalog),
|
||||
),
|
||||
],
|
||||
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
|
||||
note: None,
|
||||
status: runtime_status(&instance.runtime, catalog),
|
||||
can_launch: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
|
||||
),
|
||||
can_stop: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
|
||||
),
|
||||
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
|
||||
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
|
||||
launch: LaunchFacts {
|
||||
allowed: instance.launch.graphics_backend_allowed,
|
||||
mc_version: instance.launch.mc_version.clone(),
|
||||
min_version: instance.launch.graphics_backend_min_version.clone(),
|
||||
memory_effective_mb: instance.launch.memory_effective_mb,
|
||||
java_effective: instance.launch.java_effective.clone(),
|
||||
backend_effective: instance.launch.graphics_backend_effective.clone(),
|
||||
options: instance.launch.graphics_backend_options.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn meta(label: &str, value: &str) -> Meta {
|
||||
Meta {
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
|
||||
///
|
||||
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
|
||||
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
|
||||
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
let Some(text) = raw else {
|
||||
return catalog.text(Key::InstancesNeverPlayed).to_string();
|
||||
};
|
||||
match (text.get(0..10), text.get(11..16)) {
|
||||
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
|
||||
format!("{date} {time} UTC")
|
||||
}
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! 日志页的展示转换:presenter 快照 → [`pages::Logs`](从 `app/view.rs` 拆出来,M95 r3)。
|
||||
//!
|
||||
//! ## 跨会话串场(M95 r3 的 p2,代码复审抓的)
|
||||
//!
|
||||
//! r2 的写法先按 `state.diagnosis` 定「这是哪一场」:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let session = report.and_then(|r| log_sessions.find(|s| s.id == r.session)).or_else(|| …selected…);
|
||||
//! ```
|
||||
//!
|
||||
//! 而 `state.diagnosis` **全仓只写不清**(`presenter::state::apply_core` 里
|
||||
//! `CoreEvent::DiagnosisReady` 是唯一赋值点,没有任何清零点),host 也只在崩溃 / 打开会话 /
|
||||
//! 导入崩溃文件时发它。于是「崩溃一场 → 再启动一场正常跑」这个最普通的路径下:
|
||||
//! 页头把**新**会话标成上一场的 `The game crashed`、诊断卡挂着上一场的结论,而用户复制去贴
|
||||
//! issue 的摘要里 `lines=` 是新的那一场的行数——**结论与行数不同源**,用户会把错诊断递出去。
|
||||
//!
|
||||
//! 现在的口径(不动 presenter 的写入口,只在页面侧判别):
|
||||
//!
|
||||
//! | 概念 | 取值 |
|
||||
//! |---|---|
|
||||
//! | **身份**(这一场是谁) | `live_session`(正在跑)→ `selected_session`(打开/刚结束的),**不从 `diagnosis` 反推** |
|
||||
//! | 诊断结论 | 见下表的三个分支(r4 修正了 r3 的一条**回归**:r3 只认身份,把「导入崩溃文件」与「打开会话」两条路径的诊断一并关掉了) |
|
||||
//! | 实例/时间/结局(给页头描述) | 身份优先;身份为空才退到报告的 session |
|
||||
//!
|
||||
//! 归属判据(`report.session` 与身份的关系):
|
||||
//!
|
||||
//! | `live_session` | `selected_session` | 报告算数的条件 |
|
||||
//! |---|---|---|
|
||||
//! | `Some(live)` | 任意 | `report.session == live` —— 只有它在线时日志行是它自己在追加,别人的报告挂上来就是串场 |
|
||||
//! | `None` | `Some(sel)` | `report.session == sel` **或** 报告不属于任何已知会话(`ImportCrashLog` 用 `file:<文件名>` 当会话 id,永远不在索引里) |
|
||||
//! | `None` | `None` | 全部算数 —— 此时日志行只可能来自 `open_session` 或导入,两者都与报告同一次调用发出 |
|
||||
//!
|
||||
//! 已知残留(今天 iced 侧不可达,登记在报告里):`open_session(A)` 在 `selected_session` 仍停在上
|
||||
//! 一场 B 时会被第三行判据挡掉(A 是已知会话且 ≠ B)——真正要修得让 presenter 在
|
||||
//! `LogLinesChanged` 时把「日志行属于哪一场」记下来(`AppState` 今天没有这个字段)。
|
||||
//!
|
||||
//! 「没有命中规则」的**占位报告**也不画:host 的 `open_session` 在规则引擎无结论时会发一份
|
||||
//! `rule: ""` / `confidence: 0` / `title: "该会话没有命中已知规则"` 的假报告(`CoreEvent::DiagnosisReady`
|
||||
//! 是非 Option,host 无法表达「没有结论」)——它是标记不是结论,画出来等于给用户看一条「严重度提示」。
|
||||
//!
|
||||
//! 已知缺口(已报 finding,不在本 mission 修):`clear_logs` 之后 `selected_session` 不清,
|
||||
//! 身份会继续指向那一场;导入崩溃文件只发 `DiagnosisReady`(不带日志行),所以页面上会出现
|
||||
//! 「结论 + 上一场的日志正文」——行由 `open_session` 负责,那是 presenter 的缺口。
|
||||
|
||||
use unlucky_presenter::dto::{Diagnosis, LogLevel, LogOutcome};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages;
|
||||
|
||||
/// 日志页视图模型:presenter 快照 → 要画的东西。
|
||||
///
|
||||
/// 两处 DTO 侧的**信息丢失**(已报 finding):`dto::Diagnosis` 没有 core 的 `severity`
|
||||
/// (所以页面按置信度档分组,不冒称严重度)与 `cause.key`(`title`/`hint` 只能当数据画)。
|
||||
pub(super) fn logs_model(state: &AppState) -> pages::Logs {
|
||||
let live = state.live_session.as_deref();
|
||||
let selected = state.selected_session.as_deref();
|
||||
let identity = live.or(selected);
|
||||
|
||||
// 归属判据(r4 修正 r3 的**回归**:r3 的「有选中就只认选中」把导入/打开这两条路径的诊断
|
||||
// 一起关掉了)。三条命中任一即认:
|
||||
//
|
||||
// 1. 报告属于**正在跑**的那一场 —— 这一条必须单独成立:只有它在线时日志行是它自己在追加,
|
||||
// 别的会话的报告挂上来就是串场(r3 修的正是这个);
|
||||
// 2. 报告属于**当前选中**的那一场(没有 live 会话时);
|
||||
// 3. 报告的会话**不在会话索引里** —— `ImportCrashLog` 用 `file:<文件名>` 当会话 id,
|
||||
// `open_session` 也不一定把 id 写进 `selected_session`;这两条路径与报告是同一次调用发出的,
|
||||
// 把它们一并关掉就是把「导入崩溃日志」这条功能画成空(r3 的实测回归)。
|
||||
let known = |id: &str| state.log_sessions.iter().any(|session| session.id == id);
|
||||
let report = state.diagnosis.as_ref().filter(|report| {
|
||||
let id = report.session.as_str();
|
||||
match (live, selected) {
|
||||
// 有会话在跑:日志行是它自己在追加,别的会话的报告挂上来就是串场(r3 修的 P1)。
|
||||
(Some(live), _) => id == live,
|
||||
// 没有在跑、但有选中项:报告要么是它的,要么是「会话索引里没有的那一份」
|
||||
// (`ImportCrashLog` 用 `file:<文件名>`;`open_session` 不写 `selected_session`,
|
||||
// 所以只能靠「不在索引里」认它)。
|
||||
(None, Some(selected)) => id == selected || !known(id),
|
||||
// 什么身份都没有:日志行只可能来自 `open_session` 或导入,两者与报告同一次发出。
|
||||
(None, None) => true,
|
||||
}
|
||||
});
|
||||
|
||||
// 身份为空时,描述的来源只能退到报告(见模块文档的表格)。
|
||||
let described = identity.or_else(|| report.map(|report| report.session.as_str()));
|
||||
let session =
|
||||
described.and_then(|id| state.log_sessions.iter().find(|session| session.id == id));
|
||||
|
||||
pages::Logs {
|
||||
instance: session.map(|session| session.instance.clone()),
|
||||
when: session.map(|session| session.when.clone()),
|
||||
outcome: session.map(|session| log_outcome(session.outcome)),
|
||||
// 运行中的会话不在 `log_sessions` 里(索引只在结束时追加),所以页头要靠这个标记
|
||||
// 走「正在运行」那条文案,而不是「当前没有运行中的实例」。
|
||||
live: live.is_some(),
|
||||
lines: state
|
||||
.log_lines
|
||||
.iter()
|
||||
.map(|line| pages::Line {
|
||||
text: line.text.clone(),
|
||||
level: log_level(line.level),
|
||||
})
|
||||
.collect(),
|
||||
findings: report
|
||||
.filter(|report| !report.diagnosis.rule.is_empty())
|
||||
.map(|report| diagnosis_of(&report.diagnosis))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..pages::Logs::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志等级:presenter 枚举 → 页面等级(**结构化出口**,不是认字符串)。
|
||||
fn log_level(level: LogLevel) -> pages::Level {
|
||||
match level {
|
||||
LogLevel::Debug => pages::Level::Debug,
|
||||
LogLevel::Info => pages::Level::Info,
|
||||
LogLevel::Warn => pages::Level::Warn,
|
||||
LogLevel::Error => pages::Level::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话结局:presenter 枚举 → 页面结局(文案走 `LaunchOutcome*` key,页面不画 core 的中文串)。
|
||||
fn log_outcome(outcome: LogOutcome) -> pages::Outcome {
|
||||
match outcome {
|
||||
LogOutcome::Ok => pages::Outcome::Ok,
|
||||
LogOutcome::Crashed => pages::Outcome::Crashed,
|
||||
LogOutcome::SilentFail => pages::Outcome::SilentFail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条诊断结论 → 页面结论(置信度档见 [`pages::Confidence`])。
|
||||
fn diagnosis_of(diagnosis: &Diagnosis) -> pages::Finding {
|
||||
pages::Finding {
|
||||
rule: diagnosis.rule.clone(),
|
||||
confidence_band: pages::Confidence::of(diagnosis.confidence),
|
||||
title: diagnosis.title.clone(),
|
||||
hint: diagnosis.hint.clone(),
|
||||
confidence: diagnosis.confidence,
|
||||
action: diagnosis.fix.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 日志页展示转换的守门测试(从 `app/view/logs.rs` 拆出:那边已经 382 行,贴着 400 硬上限)。
|
||||
//!
|
||||
//! 全部走**真实 reducer 路径**(`AppState::apply_core`),不造注入源——r3 的教训是
|
||||
//! 「测试全走注入源而真实路径零覆盖」,所以这里的夹具就是线上那串事件本身。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
CoreEvent, DiagnosisReport, LaunchFinished, LiveLogBatch, LogLevel, LogLine, LogOutcome,
|
||||
LogSession,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use super::logs_model;
|
||||
|
||||
/// 崩溃的那一场。
|
||||
const CRASH: &str = "s-1029-1840";
|
||||
/// 崩溃之后又启动的、正常跑的一场。
|
||||
const OK_RUN: &str = "s-1029-1922";
|
||||
|
||||
fn session(id: &str, when: &str, outcome: LogOutcome) -> LogSession {
|
||||
LogSession {
|
||||
id: id.to_string(),
|
||||
instance: "开发测试".to_string(),
|
||||
when: when.to_string(),
|
||||
outcome,
|
||||
duration_secs: 62,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(text: &str, level: LogLevel) -> LogLine {
|
||||
LogLine {
|
||||
text: text.to_string(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
|
||||
fn report(session: &str, rule: &str) -> DiagnosisReport {
|
||||
DiagnosisReport {
|
||||
session: session.to_string(),
|
||||
diagnosis: pages_dto(rule),
|
||||
}
|
||||
}
|
||||
|
||||
fn pages_dto(rule: &str) -> unlucky_presenter::dto::Diagnosis {
|
||||
unlucky_presenter::dto::Diagnosis {
|
||||
rule: rule.to_string(),
|
||||
title: "显卡驱动过旧(OpenGL 版本不足)".to_string(),
|
||||
confidence: 92,
|
||||
hint: "升级显卡驱动".to_string(),
|
||||
fix: Some("change_graphics.driver".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// **真实数据路径**(复审要求:不许造注入源绕过去):
|
||||
/// 崩溃一场跑完 → 再启动一场正常跑。`state.diagnosis` 在这里**故意不清**,
|
||||
/// 因为 presenter 里根本没有清零点——这就是线上发生串场的条件。
|
||||
fn crashed_only() -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply_core(CoreEvent::LogSessionsChanged(vec![
|
||||
session(CRASH, "10-29 18:40", LogOutcome::Crashed),
|
||||
session(OK_RUN, "10-29 19:22", LogOutcome::Ok),
|
||||
]));
|
||||
|
||||
// 第一场跑起来(live_session = CRASH)→ 日志 → 规则结论 → 收尾(selected_session = CRASH)
|
||||
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
|
||||
session: CRASH.to_string(),
|
||||
lines: vec![line("[18:40] boom: GLFW error", LogLevel::Error)],
|
||||
running: true,
|
||||
}));
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
|
||||
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// 崩溃之后又启动一场正常跑:`live_session` 前进,`diagnosis` 仍是上一场的。
|
||||
fn after_a_new_run() -> AppState {
|
||||
let mut state = crashed_only();
|
||||
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
|
||||
session: OK_RUN.to_string(),
|
||||
lines: vec![line("[19:22] launching 1.20.4", LogLevel::Info)],
|
||||
running: true,
|
||||
}));
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_run_does_not_inherit_the_previous_crash() {
|
||||
let state = after_a_new_run();
|
||||
let logs = logs_model(&state).with_status(Lang::En);
|
||||
|
||||
// 抓得住:身份被上一场的 `diagnosis.session` 顶掉(r2 的 bug)——那时 outcome 会是
|
||||
// Crashed、findings 挂着 gpu.driver_old、摘要里 `lines=` 与结论不同源。
|
||||
// 抓不住:presenter 自己 `clear_logs` 后 `selected_session` 不清的缺口(已报 finding),
|
||||
// 以及多场并行会话(本仓一次只跑一场)。
|
||||
assert_eq!(
|
||||
logs.outcome,
|
||||
Some(pages::Outcome::Ok),
|
||||
"身份应该是正在跑的这一场"
|
||||
);
|
||||
|
||||
let status = logs.status.clone().expect("有会话身份就该有副标题");
|
||||
assert!(status.contains("Finished normally"), "{status}");
|
||||
assert!(
|
||||
!status.contains("The game crashed"),
|
||||
"页头把新会话标成了上一场的崩溃:{status}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
logs.findings.is_empty(),
|
||||
"上一场的结论不能挂到这一场:{:?}",
|
||||
logs.findings
|
||||
);
|
||||
|
||||
// 摘要必须与身份同源:outcome 是 ok、不含旧规则、行数就是当前这些行。
|
||||
let summary = logs.summary();
|
||||
assert!(summary.contains("outcome=ok"), "{summary}");
|
||||
assert!(!summary.contains("gpu.driver_old"), "{summary}");
|
||||
assert!(
|
||||
summary.contains(&format!("lines={}", logs.lines.len())),
|
||||
"{summary}"
|
||||
);
|
||||
assert!(!summary.contains("findings="), "{summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crash_itself_is_still_reported() {
|
||||
// 上一条测试的**对照**:崩溃当场必须画结论,否则那条断言「findings 为空」是空的。
|
||||
// 抓得住:把判别写成「永远不画诊断」(用空断言骗过 P2)。
|
||||
// 抓不住:结论排序/去重(那是 core 的规则引擎的事)。
|
||||
let state = crashed_only();
|
||||
let logs = logs_model(&state).with_status(Lang::En);
|
||||
|
||||
assert_eq!(logs.outcome, Some(pages::Outcome::Crashed));
|
||||
assert_eq!(logs.findings.len(), 1, "{:?}", logs.findings);
|
||||
assert_eq!(logs.findings[0].rule, "gpu.driver_old");
|
||||
let status = logs.status.clone().unwrap_or_default();
|
||||
assert!(status.contains("The game crashed"), "{status}");
|
||||
assert!(logs.summary().contains("gpu.driver_old:high(92)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_crash_file_still_shows_its_findings() {
|
||||
// r3 的**回归**:r3 把归属判据写成「有选中就只认选中」,于是导入崩溃日志
|
||||
// (`ImportCrashLog` → `DiagnosisReady` 带 `file:<文件名>` 的会话 id,且**不发**
|
||||
// `LogLinesChanged`)被整条丢掉。今天 iced 侧还没有 dispatch 站点(那些还在遗留的
|
||||
// Slint shell 里),所以复审没判 p1;这条测试是为了**将来接线的人**不再踩。
|
||||
// 抓得住:只有「属于当前选中/live 会话」才画的窄判据(导入的 `file:` 会话不等于任何
|
||||
// 索引里的会话,会被误杀)。
|
||||
// 抓不住:导入后日志正文该显示什么(`ImportCrashLog` 不载入日志行,那是 presenter 的事)。
|
||||
let mut state = AppState::new();
|
||||
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
|
||||
CRASH,
|
||||
"10-29 18:40",
|
||||
LogOutcome::Crashed,
|
||||
)]));
|
||||
// 上一场刚结束 ⇒ selected_session = CRASH(过期选中项),随后用户导入一份崩溃文件
|
||||
state.apply_core(CoreEvent::LaunchFinished(LaunchFinished {
|
||||
id: "dev".to_string(),
|
||||
session: CRASH.to_string(),
|
||||
outcome: LogOutcome::Crashed,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 3_000,
|
||||
failure: None,
|
||||
}));
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(
|
||||
"file:crash-2026-11-02_19.40.15-client.txt",
|
||||
"integrity.unsatisfied_link",
|
||||
)));
|
||||
|
||||
let logs = logs_model(&state).with_status(Lang::En);
|
||||
assert_eq!(
|
||||
logs.findings.len(),
|
||||
1,
|
||||
"导入的崩溃日志必须画出它的结论:{:?}",
|
||||
logs.findings
|
||||
);
|
||||
assert_eq!(logs.findings[0].rule, "integrity.unsatisfied_link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_run_says_it_is_running_instead_of_nothing_runs() {
|
||||
// 视觉复审 r3 的 P2-3:session 索引只在**结束**时追加(`host::logs::sessions`),
|
||||
// 所以运行中的那一场永远不在 `log_sessions` 里 ⇒ 拿不到实例名与结局 ⇒ r3 的页头在
|
||||
// 每次实时运行都说「当前没有运行中的实例」。demo 伪造了身份,截图上根本照不出来。
|
||||
// 抓得住:页头退回 Idle 文案(这次走真实 reducer:只有 `LogAppended(running=true)`)。
|
||||
// 抓不住:文案长度在某尺寸下会不会撞右侧动作区(那要看截图)。
|
||||
let mut state = AppState::new();
|
||||
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
|
||||
CRASH,
|
||||
"10-29 18:40",
|
||||
LogOutcome::Crashed,
|
||||
)]));
|
||||
state.apply_core(CoreEvent::LogAppended(LiveLogBatch {
|
||||
session: OK_RUN.to_string(),
|
||||
lines: vec![line("[19:22] launching", LogLevel::Info)],
|
||||
running: true,
|
||||
}));
|
||||
|
||||
let logs = logs_model(&state).with_status(Lang::En);
|
||||
assert!(logs.live, "live_session 非空就该标记为运行中");
|
||||
let status = logs.status.clone().expect("运行中必须有副标题");
|
||||
let idle = unlucky_i18n::catalog::Catalog::of(Lang::En)
|
||||
.text(unlucky_i18n::catalog::Key::LogsSubtitleIdle);
|
||||
assert_ne!(status, idle, "运行中的页头不能说「没有运行」");
|
||||
assert!(status.contains("Running now"), "{status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hosts_no_rule_matched_placeholder_is_not_a_finding() {
|
||||
// host 的 `open_session` 在规则引擎无结论时发一份 `rule: ""` 的假报告(非 Option 逼的)。
|
||||
// 抓得住:把「没命中」画成一条严重度结论(用户会去修一个不存在的毛病)。
|
||||
// 抓不住:host 该不该改发 `None`(那是 presenter 的 finding)。
|
||||
let mut state = AppState::new();
|
||||
state.apply_core(CoreEvent::LogSessionsChanged(vec![session(
|
||||
CRASH,
|
||||
"10-29 18:40",
|
||||
LogOutcome::Crashed,
|
||||
)]));
|
||||
state.apply_core(CoreEvent::LogLinesChanged(vec![line(
|
||||
"[18:40] nothing to see",
|
||||
LogLevel::Info,
|
||||
)]));
|
||||
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "")));
|
||||
assert!(
|
||||
logs_model(&state).findings.is_empty(),
|
||||
"空 rule 是『没命中』的占位,不是结论"
|
||||
);
|
||||
|
||||
state.apply_core(CoreEvent::DiagnosisReady(report(CRASH, "gpu.driver_old")));
|
||||
assert_eq!(
|
||||
logs_model(&state).findings.len(),
|
||||
1,
|
||||
"换成真规则就得画出来(证明上面的过滤不是把诊断一起关了)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! 导航轨底部**账户头像位**的状态换算(M114)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关——
|
||||
//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」。
|
||||
|
||||
use crate::shell;
|
||||
|
||||
use super::super::state::App;
|
||||
|
||||
/// 导航轨收成「图标条」的窗口高度(M114)。
|
||||
///
|
||||
/// 判据不是「项数」而是**真实可用高度**:四项 + 底部头像位在 48 标题栏之上还要放得下
|
||||
/// 4 × 52 + 4 × 4 + 52 ≈ 300;留出一屏正文的最小高度(424 行宽 + 页头 + 边距),
|
||||
/// 低于 720 就把标签收掉,只留图标与头像位(头像位**永远可见**,任务要求)。
|
||||
const RAIL_COMPACT_HEIGHT: f32 = 720.0;
|
||||
|
||||
impl App {
|
||||
/// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。
|
||||
pub(super) fn rail_account(&self) -> shell::RailAccount {
|
||||
let compact = self.height < RAIL_COMPACT_HEIGHT;
|
||||
match self.state.active_account() {
|
||||
Some(account) => {
|
||||
shell::RailAccount::signed_in(account.monogram(), account.name.clone())
|
||||
.compact(compact)
|
||||
}
|
||||
// M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。
|
||||
None => shell::RailAccount::signed_out(
|
||||
self.lang()
|
||||
.catalog()
|
||||
.text(unlucky_i18n::Key::AccountRailSignedOut),
|
||||
)
|
||||
.compact(compact),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_i18n::Key;
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
#[path = "tests/launch.rs"]
|
||||
mod launch;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
name: "Survival".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
||||
runtime,
|
||||
icon: None,
|
||||
isolation: "isolated".to_string(),
|
||||
version_id: "1.20.4".to_string(),
|
||||
version_present: true,
|
||||
launch: unlucky_presenter::dto::LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
|
||||
///
|
||||
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
|
||||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
|
||||
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
|
||||
let draft = pages::InstallDraft::default();
|
||||
let page = pages::content::PageState::default();
|
||||
model(
|
||||
state,
|
||||
1180.0,
|
||||
760.0,
|
||||
forced,
|
||||
pages::Category::All,
|
||||
&draft,
|
||||
lang,
|
||||
&page,
|
||||
)
|
||||
}
|
||||
|
||||
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
at_lang(state, forced, Lang::En)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_forced_state_maps_to_the_expected_interaction() {
|
||||
let state = AppState::new();
|
||||
let forced = |forced| at(&state, Some(forced)).forced;
|
||||
|
||||
assert_eq!(
|
||||
forced(crate::cli::ForcedState::Rest),
|
||||
Some(Interaction::Enabled),
|
||||
"Rest = 静息态(压掉系统光标 hover)"
|
||||
);
|
||||
assert_eq!(
|
||||
forced(crate::cli::ForcedState::Hover),
|
||||
Some(Interaction::Hovered)
|
||||
);
|
||||
assert_eq!(
|
||||
forced(crate::cli::ForcedState::Pressed),
|
||||
Some(Interaction::Pressed)
|
||||
);
|
||||
assert_eq!(at(&state, None).forced, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_snapshot_becomes_the_page_model() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = at(&state, None);
|
||||
assert_eq!(model.screen, pages::Screen::Instances);
|
||||
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
||||
assert_eq!(model.window_width, 1180.0);
|
||||
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
|
||||
assert_eq!(model.instances.rows[0].id, "survival");
|
||||
|
||||
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
|
||||
assert_eq!(detail.id, "survival");
|
||||
assert_eq!(detail.meta.len(), 6);
|
||||
assert!(detail.can_launch, "空闲实例可启动");
|
||||
assert!(!detail.can_stop);
|
||||
assert!(detail.note.is_none(), "真快照没有正文段");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_instance_offers_stop_instead_of_play() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
|
||||
InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Download,
|
||||
progress: Progress {
|
||||
done: 750,
|
||||
total: 1_500,
|
||||
},
|
||||
},
|
||||
)]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = at(&state, None);
|
||||
assert_eq!(
|
||||
model.instances.rows[0].status.as_deref(),
|
||||
Some("Downloading · 50%")
|
||||
);
|
||||
let detail = model.instances.detail.expect("有详情");
|
||||
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
|
||||
assert!(detail.can_stop, "启动中要能 Stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
assert_eq!(
|
||||
last_played(Some("2026-10-29T19:22:00Z"), en),
|
||||
"2026-10-29 19:22 UTC"
|
||||
);
|
||||
assert_eq!(last_played(None, en), "Never");
|
||||
// 短串 / 非 ASCII 原样返回(不 panic)
|
||||
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
|
||||
assert_eq!(last_played(Some("昨天"), en), "昨天");
|
||||
// 「从未游玩」是唯一走词表的那一支
|
||||
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
|
||||
}
|
||||
|
||||
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
|
||||
///
|
||||
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
|
||||
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
|
||||
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
|
||||
#[test]
|
||||
fn the_runtime_lines_and_meta_labels_follow_the_language() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
|
||||
instance(InstanceRuntime::Running { pid: 1234 }),
|
||||
instance(InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Verify,
|
||||
progress: Progress {
|
||||
done: 750,
|
||||
total: 1_500,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let en = at_lang(&state, None, Lang::En);
|
||||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||||
|
||||
// 列表 overline:中英各一份,数字与分隔符不动
|
||||
assert_eq!(
|
||||
en.instances.rows[0].status.as_deref(),
|
||||
Some("Running · pid 1234")
|
||||
);
|
||||
assert_eq!(
|
||||
zh.instances.rows[0].status.as_deref(),
|
||||
Some("运行中 · pid 1234")
|
||||
);
|
||||
assert_eq!(
|
||||
en.instances.rows[1].status.as_deref(),
|
||||
Some("Verifying · 50%")
|
||||
);
|
||||
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
|
||||
|
||||
// 元信息卡的标签与值
|
||||
let (en_detail, zh_detail) = (
|
||||
en.instances.detail.expect("有详情"),
|
||||
zh.instances.detail.expect("有详情"),
|
||||
);
|
||||
let labels = |detail: &pages::Detail| -> Vec<String> {
|
||||
detail.meta.iter().map(|cell| cell.label.clone()).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
labels(&en_detail),
|
||||
vec![
|
||||
"VERSION",
|
||||
"LOADER",
|
||||
"ISOLATION",
|
||||
"VERSION ID",
|
||||
"FILES",
|
||||
"LAST PLAYED"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
labels(&zh_detail),
|
||||
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
|
||||
);
|
||||
|
||||
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
|
||||
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
|
||||
assert_eq!(en_detail.meta[2].value, "Isolated");
|
||||
assert_eq!(zh_detail.meta[2].value, "独立目录");
|
||||
assert_eq!(en_detail.meta[4].value, "Present on disk");
|
||||
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
|
||||
assert_eq!(
|
||||
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
|
||||
"时间戳是数据:只切片,不随语言变形"
|
||||
);
|
||||
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
|
||||
|
||||
// 详情状态行与列表 overline 同源
|
||||
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
|
||||
}
|
||||
|
||||
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
|
||||
///
|
||||
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
|
||||
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
|
||||
#[test]
|
||||
fn the_vanilla_badge_follows_the_language() {
|
||||
let mut bare = instance(InstanceRuntime::Idle);
|
||||
bare.loader = LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
};
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let en = at_lang(&state, None, Lang::En);
|
||||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||||
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
|
||||
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
|
||||
}
|
||||
|
||||
/// **品牌名不许被「版本号为空」吞掉**(M120 r1 复审的 p2-1 回归)。
|
||||
///
|
||||
/// 背景:`host::game::loader_from_id("1.20.1-forge")` 这类 id 里取不到版本段(它按 `forge-`
|
||||
/// 找,而 id 的写法是 `-forge` 结尾),于是 `LoaderInfo { kind: Forge, version: "" }`。
|
||||
/// `LoaderInfo::badge()` 对这种输入给的正是品牌名 `Forge`——所以判据只能是「类型是原版」,
|
||||
/// 不能是「版本号为空」。
|
||||
///
|
||||
/// 三个 id 各一条断言(复审点名的三档);`Vanilla` 那一支由
|
||||
/// `the_vanilla_badge_follows_the_language` 守,两条合起来把 `loader_badge` 的两侧都钉住。
|
||||
///
|
||||
/// 抓得住:`loader_badge` 把「非原版 + 空版本号」也送进 `Key::LoaderVanilla`
|
||||
/// (r1 的真实形状:`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`);
|
||||
/// 抓不住:`loader_from_id` 认不出自家 `-fabric-` / `-quilt-` 口径这个**更老**的 bug
|
||||
/// (在 presenter 里,另一条 mission)。
|
||||
#[test]
|
||||
fn the_loader_badge_keeps_the_brand_when_the_version_is_missing() {
|
||||
// (版本 id, 期望徽标) —— id 形状取自 `host::game::loader_from_id` 的三条真实输入
|
||||
let cases = [
|
||||
("1.20.1-forge", LoaderKind::Forge, "Forge"),
|
||||
("1.20.1-neoforge", LoaderKind::NeoForge, "NeoForge"),
|
||||
("1.20.1-OptiFine", LoaderKind::OptiFine, "OptiFine"),
|
||||
];
|
||||
|
||||
for (id, kind, brand) in cases {
|
||||
let mut one = instance(InstanceRuntime::Idle);
|
||||
one.loader = LoaderInfo {
|
||||
kind,
|
||||
// `loader_from_id` 对这三条 id 取不到版本段 ⇒ 空串
|
||||
version: String::new(),
|
||||
};
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![one]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
// 品牌名两套语言都不翻译 ⇒ 中英逐字相同
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let model = at_lang(&state, None, lang);
|
||||
assert_eq!(
|
||||
model.instances.rows[0].supporting,
|
||||
format!("1.20.4 · {brand}"),
|
||||
"{id} 的徽标被显示成了别的东西(新增的判据「版本号为空 ⇒ 原版」就是这条要拦的)"
|
||||
);
|
||||
let detail = model.instances.detail.expect("有详情");
|
||||
assert_eq!(
|
||||
detail.meta[1].label,
|
||||
lang.catalog().text(Key::InstancesMetaLoader)
|
||||
);
|
||||
assert_eq!(detail.meta[1].value, brand, "{id} 的 LOADER 卡值");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_route_has_no_page() {
|
||||
let mut state = AppState::new();
|
||||
state.navigate(Route::Agent);
|
||||
let model = at(&state, None);
|
||||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||||
assert_eq!(pages::Screen::of(state.route()), None);
|
||||
assert!(model.instances.detail.is_none());
|
||||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
|
||||
//!
|
||||
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
|
||||
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
// 进程**没起来**:说「哪一步 + 原文」。
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process")
|
||||
);
|
||||
// 详情页给全文(阶段 + 原文)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process: os error 5")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
|
||||
///
|
||||
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
|
||||
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
|
||||
#[test]
|
||||
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
|
||||
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
|
||||
|
||||
let line = |cause: LaunchFailureCause, lang: Lang| {
|
||||
let runtime = InstanceRuntime::Failed { cause };
|
||||
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
|
||||
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
|
||||
(row, detail)
|
||||
};
|
||||
|
||||
// ① 没起来:说阶段。
|
||||
let (row, detail) = line(
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
Lang::En,
|
||||
);
|
||||
assert_eq!(row, "Launch failed · Starting the game process");
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Launch failed · Starting the game process: os error 5"
|
||||
);
|
||||
|
||||
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
|
||||
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
|
||||
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
|
||||
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
|
||||
assert!(
|
||||
!detail.contains("Starting the game process"),
|
||||
"进程起来了,不许说「启动游戏进程」:{detail}"
|
||||
);
|
||||
|
||||
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
|
||||
let (row, zh) = line(
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
Lang::ZhCn,
|
||||
);
|
||||
assert_eq!(row, "启动失败 · 退出码 0,没起来");
|
||||
assert_eq!(zh, row);
|
||||
assert!(row.contains("没起来"), "{row}");
|
||||
|
||||
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
|
||||
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · The game crashed");
|
||||
}
|
||||
|
||||
///
|
||||
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||
///
|
||||
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||
#[test]
|
||||
fn the_launch_toast_speaks_the_interface_language() {
|
||||
use unlucky_presenter::dto::{
|
||||
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
let finished = |outcome, failure| LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_500,
|
||||
failure,
|
||||
};
|
||||
|
||||
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||
let ok = finished(LogOutcome::Ok, None);
|
||||
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||
assert_eq!(en, "Finished normally · 12.5s");
|
||||
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||
assert_ne!(en, zh, "切语言时提示语要变");
|
||||
|
||||
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||
let failed = finished(
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::InstanceConfig,
|
||||
"permission denied (os error 5)",
|
||||
)),
|
||||
);
|
||||
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||
assert!(
|
||||
en.starts_with("Launch failed · Reading the instance settings"),
|
||||
"{en}"
|
||||
);
|
||||
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||
for text in [&en, &zh] {
|
||||
assert!(
|
||||
text.contains("permission denied (os error 5)"),
|
||||
"原文(取证信息)不许丢:{text}"
|
||||
);
|
||||
}
|
||||
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||
assert!(
|
||||
!zh.contains("Launch failed"),
|
||||
"中文提示里混了英文前缀:{zh}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! presenter 的更新快照 → 页面视图模型(M159 的**唯一**展示转换点)。
|
||||
//!
|
||||
//! 与 `view/instances.rs` 同一条分工:判断(要不要提示、是不是失败)在 presenter / core 已经做完,
|
||||
//! 这里只把字段摆成「要画什么」。三条映射必须与 DTO 的判据保持一致:
|
||||
//!
|
||||
//! | DTO | 页面视图 |
|
||||
//! |---|---|
|
||||
//! | `UpdateOutcome::UpToDate` | [`UpdateStatus::UpToDate`](**不是**失败) |
|
||||
//! | `UpdateOutcome::Failed` | [`UpdateStatus::Failed`](带原因,**不许**画成「已是最新」) |
|
||||
//! | `UpdateOutcome::MissingArtifact` | [`UpdateStatus::MissingArtifact`](发布侧漏项,单独一句话) |
|
||||
|
||||
use unlucky_presenter::dto::{UpdateOutcome, UpdateView};
|
||||
|
||||
use crate::pages::{Update, UpdateStatus};
|
||||
|
||||
/// 快照 → 视图模型。
|
||||
pub(super) fn update_of(view: &UpdateView) -> Update {
|
||||
Update {
|
||||
current_version: view.current_version.clone(),
|
||||
channel: view.channel.clone(),
|
||||
auto_check: view.auto_check,
|
||||
status: status_of(&view.outcome),
|
||||
checked_at: view.checked_at.clone(),
|
||||
applying: view.applying,
|
||||
ready: view.ready.as_ref().map(|ready| ready.version.clone()),
|
||||
skipped: view.skipped.clone(),
|
||||
silent: view.silent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 结论 → 页面状态(四种结论一一对应,**不折叠**)。
|
||||
fn status_of(outcome: &UpdateOutcome) -> UpdateStatus {
|
||||
match outcome {
|
||||
UpdateOutcome::Idle => UpdateStatus::Idle,
|
||||
UpdateOutcome::Checking => UpdateStatus::Checking,
|
||||
UpdateOutcome::UpToDate { latest } => UpdateStatus::UpToDate {
|
||||
latest: latest.clone(),
|
||||
},
|
||||
UpdateOutcome::Available(candidate) => UpdateStatus::Available {
|
||||
version: candidate.version.clone(),
|
||||
size: candidate.size,
|
||||
notes: candidate.notes.clone(),
|
||||
forced: candidate.forced,
|
||||
},
|
||||
UpdateOutcome::MissingArtifact { version } => UpdateStatus::MissingArtifact {
|
||||
version: version.clone(),
|
||||
},
|
||||
UpdateOutcome::Failed(failure) => UpdateStatus::Failed {
|
||||
reason: failure.message.clone(),
|
||||
retryable: failure.retryable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_presenter::dto::{ReadyUpdate, UpdateFailureView};
|
||||
|
||||
fn view(outcome: UpdateOutcome) -> UpdateView {
|
||||
let mut view = UpdateView::new("0.2.2", "stable", true);
|
||||
view.outcome = outcome;
|
||||
view
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_is_never_mapped_to_up_to_date() {
|
||||
let mapped = update_of(&view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"取更新清单失败:连接被重置",
|
||||
true,
|
||||
))));
|
||||
assert!(
|
||||
matches!(&mapped.status, UpdateStatus::Failed { reason, retryable } if *retryable && !reason.is_empty()),
|
||||
"失败必须原样带原因,实际 {:?}",
|
||||
mapped.status
|
||||
);
|
||||
assert!(mapped.banner().is_some(), "主动查出来的失败要弹提示条");
|
||||
|
||||
// 静默检查的失败:快照里仍是失败,但不弹条(离线是常态)。
|
||||
let mut silent = view(UpdateOutcome::Failed(UpdateFailureView::new(
|
||||
"network",
|
||||
"连不上",
|
||||
true,
|
||||
)));
|
||||
silent.silent = true;
|
||||
let mapped = update_of(&silent);
|
||||
assert!(matches!(mapped.status, UpdateStatus::Failed { .. }));
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_outcomes_stay_distinct() {
|
||||
let available = update_of(&view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 21_000_000,
|
||||
notes: "修掉启动崩溃".to_string(),
|
||||
forced: false,
|
||||
},
|
||||
))));
|
||||
assert!(available.available().is_some());
|
||||
assert!(matches!(
|
||||
available.banner(),
|
||||
Some(crate::pages::Banner::Available { .. })
|
||||
));
|
||||
|
||||
let up_to_date = update_of(&view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.2".to_string()),
|
||||
}));
|
||||
assert!(matches!(up_to_date.status, UpdateStatus::UpToDate { .. }));
|
||||
assert!(up_to_date.banner().is_none());
|
||||
|
||||
let missing = update_of(&view(UpdateOutcome::MissingArtifact {
|
||||
version: "0.2.3".to_string(),
|
||||
}));
|
||||
assert!(matches!(
|
||||
missing.status,
|
||||
UpdateStatus::MissingArtifact { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ready_update_turns_the_banner_into_the_restart_prompt() {
|
||||
let mut view = view(UpdateOutcome::UpToDate {
|
||||
latest: Some("0.2.3".to_string()),
|
||||
});
|
||||
view.ready = Some(ReadyUpdate {
|
||||
version: "0.2.3".to_string(),
|
||||
path: "C:/ULL/unlucky-launcher.exe".to_string(),
|
||||
backup: None,
|
||||
});
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.ready.as_deref(), Some("0.2.3"));
|
||||
assert!(matches!(
|
||||
mapped.banner(),
|
||||
Some(crate::pages::Banner::Ready { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_update_keeps_the_snapshot_but_hides_the_banner() {
|
||||
let mut view = view(UpdateOutcome::Available(Box::new(
|
||||
unlucky_presenter::dto::UpdateCandidateView {
|
||||
version: "0.2.3".to_string(),
|
||||
size: 1,
|
||||
notes: String::new(),
|
||||
forced: false,
|
||||
},
|
||||
)));
|
||||
view.skipped = Some("0.2.3".to_string());
|
||||
let mapped = update_of(&view);
|
||||
assert_eq!(mapped.skipped.as_deref(), Some("0.2.3"));
|
||||
assert!(mapped.available().is_some(), "候选要留着给设置页");
|
||||
assert!(mapped.banner().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 整窗 α 的**执行侧**:句柄探测 → `WS_EX_LAYERED` → `SetLayeredWindowAttributes` → 一行日志。
|
||||
//!
|
||||
//! ## 为什么句柄要绕一圈 iced
|
||||
//!
|
||||
//! iced 0.14 只给 `window::Id`(它自己的编号),没有「给我 HWND」的公开 API;唯一的入口是
|
||||
//! `window::run(id, callback)`——回调里拿到的 `&dyn Window` 实现了 `HasWindowHandle`
|
||||
//! (raw-window-handle 0.6),于是能取到 `RawWindowHandle::Win32(hwnd)`。
|
||||
//!
|
||||
//! ## 三层责任(顺序不能换)
|
||||
//!
|
||||
//! 1. **探测句柄**:[`probe_hwnd`],纯取;拿不到就整条链跳过(并如实打日志,不许假装成功);
|
||||
//! 2. **补分层样式**:`ensure_layered`(唯一 `unsafe` 落点,`platform/win32/transparency.rs`);
|
||||
//! `SetLayeredWindowAttributes` 在没有 `WS_EX_LAYERED` 的窗口上**必然失败**(Win32 返回 0,
|
||||
//! 本机 `GetLastError` 常见 87),所以这一步是硬前提;
|
||||
//! 3. **下发 α**:`apply_window_alpha`。`α ≥ 1` 时它返回 `Opaque` 并且**不调 FFI**,那时也就不必
|
||||
//! 改扩展样式(1、2 步都是白改),[`dispatch`] 里按这个条件短路。
|
||||
//!
|
||||
//! ## 为什么日志要记「下发字节」而不是「看起来多透明」
|
||||
//!
|
||||
//! 本机是 RDP + 4 个虚拟显示适配器:`LWA_ALPHA` 的**观感**在这里不可信。所以证据链是
|
||||
//! `配置值 → 判据 → 字节 → Win32 返回值`,全部经 [`super::App::log_appearance`] 落成一行文本。
|
||||
|
||||
use iced::window;
|
||||
|
||||
use crate::platform::win32::transparency::{apply_window_alpha, ensure_layered, AlphaOutcome};
|
||||
|
||||
/// 一次「补样式 + 下发」的结果(日志与单测共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Dispatch {
|
||||
/// `WS_EX_LAYERED` 是否就位(`α ≥ 1` 或 `α = None` 时为 `false`:那次根本没动扩展样式)。
|
||||
pub layered: bool,
|
||||
/// 下发的结局。
|
||||
pub alpha: AlphaOutcome,
|
||||
}
|
||||
|
||||
impl Dispatch {
|
||||
/// 日志尾巴:`layered=ok outcome=Applied { byte: 205 }`。
|
||||
///
|
||||
/// `layered` 有三个值,刻意区分「没用到」与「失败」:
|
||||
/// - `n/a`:α ≥ 1 或被拒绝 —— 那次**根本不需要**分层窗口,不是错误;
|
||||
/// - `ok`:这一位在(原本就有或本条补上);
|
||||
/// - `FAILED`:需要补但补不上(无效句柄等)—— 只有这一种是坏消息。
|
||||
pub(super) fn describe(&self) -> String {
|
||||
let layered = match self.alpha {
|
||||
AlphaOutcome::Opaque | AlphaOutcome::Rejected(_) => "n/a",
|
||||
AlphaOutcome::Applied { .. } | AlphaOutcome::Failed { .. } if !self.layered => "FAILED",
|
||||
_ => "ok",
|
||||
};
|
||||
format!("layered={layered} outcome={:?}", self.alpha)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
|
||||
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
|
||||
// 只有 `RawWindowHandle::Win32` 那条臂用到它,其余平台只剩 `_ => None`
|
||||
// ⇒ 无条件导入在非 Windows 上是 `unused_imports`(M147)。
|
||||
#[cfg(target_os = "windows")]
|
||||
use window::raw_window_handle::RawWindowHandle;
|
||||
|
||||
let handle = handle.window_handle().ok()?;
|
||||
match handle.as_raw() {
|
||||
#[cfg(target_os = "windows")]
|
||||
RawWindowHandle::Win32(win32) => Some(win32.hwnd.get()),
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
_ => None,
|
||||
#[cfg(target_os = "windows")]
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 补样式 + 下发(**幂等**:`α ≥ 1` 时什么都不做,返回 `Opaque`)。
|
||||
pub(super) fn dispatch(hwnd: isize, alpha: Option<f64>) -> Dispatch {
|
||||
// α 不透明 / 被拒绝时不需要分层窗口:`SetLayeredWindowAttributes` 那次调用也不会发生,
|
||||
// 改扩展样式纯属副作用(还会让 DWM 换一条合成路径),所以短路掉。
|
||||
let needs_layered = alpha.is_some_and(|alpha| alpha < 1.0);
|
||||
let layered = needs_layered && ensure_layered(hwnd);
|
||||
Dispatch {
|
||||
layered,
|
||||
alpha: apply_window_alpha(hwnd, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dispatch;
|
||||
use crate::platform::win32::transparency::{AlphaOutcome, AlphaRejection};
|
||||
|
||||
/// 抓得住:①`α = None` / `α ≥ 1` / `α = 0` 时**不去碰扩展样式**(短路条件写反会在这里红);
|
||||
/// ②拒绝路径的返回值与 `transparency` 的判据一致。
|
||||
/// 抓不住:真实窗口(有效句柄)上 `WS_EX_LAYERED` 是否真的加上了——那要靠本机日志的 `layered=ok`。
|
||||
#[test]
|
||||
fn opaque_or_refused_alpha_never_touches_the_window_style() {
|
||||
for none in [None, Some(1.0), Some(1.5), Some(0.0), Some(-1.0)] {
|
||||
let result = dispatch(1234, none);
|
||||
assert!(!result.layered, "{none:?} 不该改扩展样式");
|
||||
}
|
||||
assert_eq!(
|
||||
dispatch(1234, None).alpha,
|
||||
AlphaOutcome::Rejected(AlphaRejection::NoAlpha)
|
||||
);
|
||||
assert_eq!(dispatch(1234, Some(1.0)).alpha, AlphaOutcome::Opaque);
|
||||
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
|
||||
let invalid = dispatch(1, Some(0.5));
|
||||
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
|
||||
// 「做不到」的语义随平台不同(M147 实证:这条断言原先按 Windows 写死,Ubuntu 上
|
||||
// 622 passed / 1 failed):Windows 上 FFI 真被调用并失败 ⇒ `Failed`;非 Windows 走
|
||||
// `transparency.rs` 的空实现 ⇒ `Unsupported`。两边都不许表现成 `Applied` / `Opaque`。
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(
|
||||
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
|
||||
"{:?}",
|
||||
invalid.alpha
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
invalid.alpha,
|
||||
AlphaOutcome::Unsupported,
|
||||
"非 Windows 的空实现必须如实说「这个平台不支持」,不许冒充成功"
|
||||
);
|
||||
}
|
||||
|
||||
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
|
||||
/// 这里只钉日志格式:`Applied { byte: 205 }` 必须能一眼读出字节,且「没用到样式」不等于失败。
|
||||
#[test]
|
||||
fn the_log_tail_carries_the_byte_and_distinguishes_skipped_from_failed() {
|
||||
let applied = super::Dispatch {
|
||||
layered: true,
|
||||
alpha: AlphaOutcome::Applied { byte: 205 },
|
||||
};
|
||||
assert!(applied.describe().starts_with("layered=ok outcome=Applied"));
|
||||
assert!(applied.describe().contains("205"));
|
||||
|
||||
// α = 1.0:没补样式是**正常**的,不许写成 FAILED(否则报告里会出现假红)。
|
||||
let opaque = super::Dispatch {
|
||||
layered: false,
|
||||
alpha: AlphaOutcome::Opaque,
|
||||
};
|
||||
assert!(opaque.describe().starts_with("layered=n/a"));
|
||||
|
||||
// 真的要补却没补上:这才是 FAILED。
|
||||
let failed = super::Dispatch {
|
||||
layered: false,
|
||||
alpha: AlphaOutcome::Failed { code: 87 },
|
||||
};
|
||||
assert!(failed.describe().starts_with("layered=FAILED"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
|
||||
//!
|
||||
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||||
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||||
//!
|
||||
//! ## 参数总表
|
||||
//!
|
||||
//! | 参数 | 取值 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
|
||||
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
|
||||
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
|
||||
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
|
||||
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
|
||||
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
|
||||
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
|
||||
//! | `--force-state` | `rest`/`hover`/`pressed` | 把整屏可交互控件强制成某态(取证);**给了 `--screenshot` 就默认 `rest`** |
|
||||
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
|
||||
//!
|
||||
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
|
||||
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
/// 渲染后端请求(D27)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Backend {
|
||||
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
|
||||
#[default]
|
||||
Auto,
|
||||
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`;拿不到适配器仍会降级,不硬失败)。
|
||||
Gpu,
|
||||
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Auto => "auto",
|
||||
Backend::Gpu => "gpu",
|
||||
Backend::Cpu => "cpu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
|
||||
pub fn note(self) -> &'static str {
|
||||
if !cfg!(feature = "gpu") {
|
||||
return "cpu(build without wgpu)";
|
||||
}
|
||||
match self {
|
||||
Backend::Cpu => "cpu(forced)",
|
||||
Backend::Gpu => "gpu(preferred)",
|
||||
Backend::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Screen {
|
||||
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
|
||||
#[default]
|
||||
Shell,
|
||||
/// 组件库 harness:整窗只放状态矩阵。
|
||||
Gallery,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Screen::Shell => "shell",
|
||||
Screen::Gallery => "gallery",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析后的启动参数。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Options {
|
||||
/// 渲染后端请求。
|
||||
pub backend: Backend,
|
||||
/// 主题:`None` = 探测系统主题。
|
||||
pub theme: Option<SchemeFlavor>,
|
||||
/// 截图通道:给了路径就出图后退出。
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// `--screenshot-theme`(优先级高于 `--theme`)。
|
||||
pub screenshot_theme: Option<SchemeFlavor>,
|
||||
/// 强制窗口缩放因子(验收要 100% DPI,即 `1`;`None` = 用系统 DPI)。
|
||||
pub scale_factor: Option<f32>,
|
||||
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
|
||||
pub diff: Option<(PathBuf, PathBuf)>,
|
||||
/// 见上(默认 0 = 不扫)。
|
||||
pub diff_scan: u32,
|
||||
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||
pub force_state: Option<ForcedState>,
|
||||
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
|
||||
pub screen: Screen,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
|
||||
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
|
||||
pub data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||
///
|
||||
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ForcedState {
|
||||
/// **常态**(静息):压掉系统光标带来的 hover/pressed。
|
||||
///
|
||||
/// 截图通道(给了 `--screenshot`)**默认就是它**,理由是实测:M99 复审在同一个 exe 上连跑 8 次,
|
||||
/// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口,
|
||||
/// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。
|
||||
/// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。
|
||||
///
|
||||
/// ⚠️ **语义边界**:`Rest` 落成 `Interaction::Enabled`,而 `components::buttons::assemble()`
|
||||
/// 把「没有 `on_press`」的按钮默认画成 **Disabled** —— 于是 `Rest` 会把一个本应禁用的按钮
|
||||
/// 画成可用。当前零影响(所有 `forced_state(` 调用点与 shell 的 caption / nav 按钮都带消息),
|
||||
/// 但给「无消息按钮」出取证图时别套 `Rest`。
|
||||
Rest,
|
||||
/// 所有可交互控件按 hover 画。
|
||||
Hover,
|
||||
/// 所有可交互控件按 pressed 画。
|
||||
Pressed,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--scale-factor`)时返回人话。
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Self::parse(std::env::args().skip(1))
|
||||
}
|
||||
|
||||
/// 从任意参数序列解析(`argv[1..]`,测试直接喂数组)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 同 [`Options::from_env`]。
|
||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||
let mut options = Self::default();
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
let (key, inline) = match arg.split_once('=') {
|
||||
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
|
||||
None => (arg.clone(), None),
|
||||
};
|
||||
|
||||
match key.as_str() {
|
||||
"--software-renderer" => options.backend = Backend::Cpu,
|
||||
"--renderer" => {
|
||||
let value = take(&mut args, inline, "--renderer")?;
|
||||
options.backend = match value.as_str() {
|
||||
"auto" => Backend::Auto,
|
||||
"gpu" => Backend::Gpu,
|
||||
"cpu" => Backend::Cpu,
|
||||
other => {
|
||||
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"));
|
||||
}
|
||||
};
|
||||
}
|
||||
"--theme" => {
|
||||
options.theme = Some(flavor(&take(&mut args, inline, "--theme")?)?);
|
||||
}
|
||||
"--screenshot-theme" => {
|
||||
options.screenshot_theme =
|
||||
Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
|
||||
}
|
||||
"--screenshot" => {
|
||||
options.screenshot =
|
||||
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
|
||||
}
|
||||
"--scale-factor" => {
|
||||
let raw = take(&mut args, inline, "--scale-factor")?;
|
||||
let value: f32 = raw
|
||||
.parse()
|
||||
.map_err(|_| format!("--scale-factor 需要数字,收到 {raw:?}"))?;
|
||||
if !(value.is_finite() && value > 0.0) {
|
||||
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
|
||||
}
|
||||
options.scale_factor = Some(value);
|
||||
}
|
||||
"--diff" => {
|
||||
let left = take(&mut args, inline, "--diff")?;
|
||||
let right = args
|
||||
.next()
|
||||
.ok_or_else(|| "--diff 需要两张图的路径".to_owned())?;
|
||||
options.diff = Some((PathBuf::from(left), PathBuf::from(right)));
|
||||
}
|
||||
"--diff-scan" => {
|
||||
let raw = take(&mut args, inline, "--diff-scan")?;
|
||||
options.diff_scan = raw
|
||||
.parse()
|
||||
.map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?;
|
||||
}
|
||||
"--force-state" => {
|
||||
let raw = take(&mut args, inline, "--force-state")?;
|
||||
options.force_state = Some(match raw.as_str() {
|
||||
"rest" => ForcedState::Rest,
|
||||
"hover" => ForcedState::Hover,
|
||||
"pressed" => ForcedState::Pressed,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"--force-state 只认 rest|hover|pressed,收到 {other:?}"
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
"--screen" => {
|
||||
let raw = take(&mut args, inline, "--screen")?;
|
||||
options.screen = match raw.as_str() {
|
||||
"shell" => Screen::Shell,
|
||||
"gallery" => Screen::Gallery,
|
||||
other => {
|
||||
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
|
||||
}
|
||||
};
|
||||
}
|
||||
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||||
"--data-dir" => {
|
||||
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||||
}
|
||||
other => return Err(format!("未知参数 {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// **截图通道默认强制 `Rest`**(M100):取证图不许跟着系统指针的位置变。
|
||||
// 交互模式保持 `None`——那是用户的真窗口,不许替用户把状态钉死(钉死了就永远看不到 hover)。
|
||||
if options.screenshot.is_some() && options.force_state.is_none() {
|
||||
options.force_state = Some(ForcedState::Rest);
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
|
||||
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||||
self.screenshot_theme.or(self.theme)
|
||||
}
|
||||
|
||||
/// 本次进程真正要画的主题(**唯一取值口**)。
|
||||
///
|
||||
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
|
||||
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
|
||||
///
|
||||
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
|
||||
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
|
||||
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
|
||||
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
|
||||
match self.screenshot {
|
||||
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
|
||||
None => self.shot_flavor(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
|
||||
pub fn wants_system_theme(&self) -> bool {
|
||||
self.capture_flavor().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn take(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
inline: Option<String>,
|
||||
flag: &str,
|
||||
) -> Result<String, String> {
|
||||
inline
|
||||
.or_else(|| args.next())
|
||||
.ok_or_else(|| format!("{flag} 需要一个值"))
|
||||
}
|
||||
|
||||
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
|
||||
match raw {
|
||||
"light" => Ok(SchemeFlavor::Light),
|
||||
"dark" => Ok(SchemeFlavor::Dark),
|
||||
other => Err(format!("--theme 只认 light|dark,收到 {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cli/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `cli::Options` 的解析断言(M100 从 `cli.rs` 内联的 `mod tests` 搬出来:那边 394 行,
|
||||
//! 加一个 `ForcedState::Rest` 就必然破 400)。
|
||||
//!
|
||||
//! 搬移**不改一行断言**:只去掉了 `mod tests { }` 那一层缩进,外加 M100 新增的
|
||||
//! `screenshots_default_to_the_rest_state`(见文件末尾)。
|
||||
//!
|
||||
//! 站位:本文件是 `crate::cli::tests`,`use super::*;` 就够(`Options` / `ForcedState` /
|
||||
//! `Backend` / `Screen` 都在 `cli` 里)。
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Options, String> {
|
||||
Options::parse(args.iter().map(|arg| (*arg).to_owned()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_auto_system_theme_and_shell() {
|
||||
let options = parse(&[]).expect("空参数要能解析");
|
||||
assert_eq!(options.backend, Backend::Auto);
|
||||
assert_eq!((options.theme, options.screenshot), (None, None));
|
||||
assert_eq!(options.screen, Screen::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_renderer_is_an_alias_of_cpu() {
|
||||
assert_eq!(
|
||||
parse(&["--software-renderer"]).unwrap().backend,
|
||||
Backend::Cpu
|
||||
);
|
||||
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
|
||||
assert_eq!(parse(&["--renderer", "gpu"]).unwrap().backend, Backend::Gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_argv_forms_are_accepted() {
|
||||
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
let inline = parse(&["--screenshot=out.png"]).unwrap();
|
||||
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
|
||||
assert_eq!(spaced.screenshot, inline.screenshot);
|
||||
assert_eq!(
|
||||
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
|
||||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screen", "gallery"]).unwrap().screen,
|
||||
Screen::Gallery
|
||||
);
|
||||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||
assert_eq!(
|
||||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||
Some(ForcedState::Hover)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_theme_wins_over_theme() {
|
||||
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(
|
||||
(o.theme, o.shot_flavor()),
|
||||
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_are_reported_not_panicked() {
|
||||
for args in [
|
||||
&["--renderer=metal"][..],
|
||||
&["--theme=blue"],
|
||||
&["--scale-factor=0"],
|
||||
&["--scale-factor=abc"],
|
||||
&["--screenshot"],
|
||||
&["--diff", "only-one.png"],
|
||||
&["--screen=settings"],
|
||||
&["--force-state=focus"],
|
||||
&["--diff-scan=-1"],
|
||||
&["--nope"],
|
||||
] {
|
||||
assert!(parse(args).is_err(), "{args:?} 应该报错");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_is_accepted_and_ignored_for_the_flip() {
|
||||
// 现役 CI 冒烟:--software-renderer --screenshot <png> --data-dir <dir>
|
||||
let options = parse(&[
|
||||
"--software-renderer",
|
||||
"--screenshot",
|
||||
"shot.png",
|
||||
"--data-dir",
|
||||
"/d/tmp/x",
|
||||
])
|
||||
.expect("翻牌前必须兼容 --data-dir");
|
||||
assert_eq!(options.backend, Backend::Cpu);
|
||||
assert_eq!(options.screenshot, Some(PathBuf::from("shot.png")));
|
||||
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
|
||||
}
|
||||
|
||||
/// **取证不变量(M100)**:截图通道默认 Rest,交互模式保持 `None`,显式要态仍然优先。
|
||||
///
|
||||
/// 抓得住:① 谁把截图默认值改回 `None`(系统指针 hover 会重新漏进图,M99 复审实测翻盘过一次);
|
||||
/// ② 谁顺手在交互模式也钉死状态(那会让真窗口永远看不到 hover)。
|
||||
/// 抓不住:**产品页**上是否还有不消费 forced 的控件——那要出图比对,测试管不了。
|
||||
#[test]
|
||||
fn screenshots_default_to_the_rest_state() {
|
||||
assert_eq!(
|
||||
parse(&["--force-state=rest"]).unwrap().force_state,
|
||||
Some(ForcedState::Rest),
|
||||
"rest 是显式的合法取值"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screenshot", "out.png"]).unwrap().force_state,
|
||||
Some(ForcedState::Rest),
|
||||
"截图通道必须默认 Rest"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screenshot", "o.png", "--force-state=hover"])
|
||||
.unwrap()
|
||||
.force_state,
|
||||
Some(ForcedState::Hover),
|
||||
"显式要态时按用户给的画(状态矩阵图靠它)"
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screenshot", "o.png", "--force-state=pressed"])
|
||||
.unwrap()
|
||||
.force_state,
|
||||
Some(ForcedState::Pressed)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&[]).unwrap().force_state,
|
||||
None,
|
||||
"交互模式不许替用户钉死状态"
|
||||
);
|
||||
assert_eq!(parse(&["--theme=dark"]).unwrap().force_state, None);
|
||||
}
|
||||
|
||||
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
|
||||
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
|
||||
#[test]
|
||||
fn screenshot_without_theme_defaults_to_light() {
|
||||
let options = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
assert_eq!(options.shot_flavor(), None, "用户没给主题");
|
||||
assert_eq!(
|
||||
options.capture_flavor(),
|
||||
Some(SchemeFlavor::Light),
|
||||
"截图模式必须给确定主题(CI 冒烟依赖这条)"
|
||||
);
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_themes_still_win_in_screenshot_mode() {
|
||||
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
|
||||
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
|
||||
let both = parse(&[
|
||||
"--screenshot",
|
||||
"a.png",
|
||||
"--theme=dark",
|
||||
"--screenshot-theme=light",
|
||||
]);
|
||||
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_mode_still_follows_the_system_theme() {
|
||||
let interactive = parse(&[]).unwrap();
|
||||
assert_eq!(interactive.capture_flavor(), None);
|
||||
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
|
||||
|
||||
let pinned = parse(&["--theme=dark"]).unwrap();
|
||||
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
assert!(!pinned.wants_system_theme());
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 装配与样式:五式共用的一条产线(尺寸 / 内边距 / 图标盒 / 状态层 / 描边)。
|
||||
//!
|
||||
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
|
||||
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, text, Row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::{Geometry, Variant};
|
||||
|
||||
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct StyleSpec {
|
||||
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
|
||||
pub fill: Option<Role>,
|
||||
/// 内容(文字 + 图标)角色。
|
||||
pub content: Role,
|
||||
/// 描边角色;`None` = 无描边。
|
||||
pub border: Option<Role>,
|
||||
/// 圆角档位(来自组件表,五式统一 `Full`)。
|
||||
pub shape: ShapeScale,
|
||||
/// 描边宽度(`Geometry::stroke`:有描边的变体 = `stroke::HAIRLINE`,其余 0)。
|
||||
pub stroke: f32,
|
||||
/// 圆角折算用的边长(药丸 = 可见高)。
|
||||
pub radius_side: f32,
|
||||
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
|
||||
///
|
||||
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
|
||||
/// 某个按钮上时那一格会被画成 hover(实测踩过:text 行 enabled 列出现不该有的灰底容器,
|
||||
/// 看起来像 Text 按钮自带容器)。
|
||||
pub forced: Option<Interaction>,
|
||||
}
|
||||
|
||||
/// 五式共用的装配:**只画一处**,五个变体函数都走这里。
|
||||
pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: Variant,
|
||||
spec: super::Button<'a, M>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = spec.state.or(if spec.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
|
||||
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
|
||||
debug_assert!(
|
||||
!matches!(
|
||||
variant.elevation(),
|
||||
Elevation::Level3 | Elevation::Level4 | Elevation::Level5
|
||||
),
|
||||
"按钮家族不许有浮层高程"
|
||||
);
|
||||
// **内容色必须跟着态走**:禁用时文字/图标是 on-surface@38%,不是满档内容色。
|
||||
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
|
||||
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
|
||||
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
|
||||
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
|
||||
|
||||
let label = text(spec.label)
|
||||
.size(ui.style(TypeRole::LabelLarge).size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(
|
||||
ui.style(TypeRole::LabelLarge).line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
|
||||
.color(ink);
|
||||
|
||||
let mut row = Row::new().align_y(Alignment::Center);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器(按钮图标 18、IconButton 24 都不例外)。
|
||||
// **必须走 `view_at`**:盒 18 ≠ `RENDER_PX`(24),用 24 烘焙再缩到 18 会把视觉线宽从 1.5 压到 1.1
|
||||
// (复审 r1 P2-2 实测 1.04~1.25px)。
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view_at::<M>(kind, ink, geometry.icon),
|
||||
));
|
||||
}
|
||||
row = row.push(label);
|
||||
if let Some(kind) = spec.trailing {
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view_at::<M>(kind, ink, geometry.icon),
|
||||
));
|
||||
}
|
||||
let row = row.spacing(geometry.gap);
|
||||
|
||||
// **垂直居中必须在内容侧做**:iced 的 `button::layout` 把内容放在 padding-box 的左上角
|
||||
// (`layout::padded` 只 `move_to(padding.top)`,不做居中),所以内容自己撑满高度再居中。
|
||||
// 少了这一层,40 高的按钮里文字会贴上沿 10px(实测首版就是这样)。
|
||||
let content_box = iced::widget::container(row)
|
||||
.height(Length::Fill)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
stroke: geometry.stroke,
|
||||
radius_side: geometry.height,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(content_box)
|
||||
.height(Length::Fixed(geometry.height))
|
||||
.padding(geometry.padding)
|
||||
.style(style);
|
||||
|
||||
match spec.on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
|
||||
///
|
||||
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
|
||||
/// (图标按钮)都调它,两条路径不会再各自钉死满档色。
|
||||
/// 内容色:**禁用时的角色由调用方按变体给**([`super::Variant::disabled_content`] /
|
||||
/// [`super::IconVariant::disabled_content`])——A1 里 FilledTonal 与 IconButton 是 `OnSurface`,
|
||||
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
|
||||
pub(super) fn content_color(
|
||||
ui: &Ui,
|
||||
forced: Option<Interaction>,
|
||||
content: Role,
|
||||
fill: Option<Role>,
|
||||
disabled_content: Role,
|
||||
) -> iced::Color {
|
||||
match forced {
|
||||
// 有容器:38% 叠在**按钮家族 10% 容器**上(Filled / Tonal / Elevated)
|
||||
Some(Interaction::Disabled) if fill.is_some() => {
|
||||
interaction::disabled_button_content(ui, disabled_content, true)
|
||||
}
|
||||
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
|
||||
Some(Interaction::Disabled) => {
|
||||
interaction::disabled_button_content(ui, disabled_content, false)
|
||||
}
|
||||
_ => ui.color(content),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 `button::style` 闭包:状态层 / 禁用 / 描边全在这里落地。
|
||||
pub(super) fn style_closure(
|
||||
ui: &Ui,
|
||||
spec: StyleSpec,
|
||||
) -> impl Fn(&iced::Theme, button::Status) -> button::Style + '_ {
|
||||
move |_theme: &iced::Theme, status| {
|
||||
// 强制态优先(gallery 状态矩阵),否则把 iced 的四态翻成 M3 五态
|
||||
let state = spec
|
||||
.forced
|
||||
.unwrap_or_else(|| Interaction::from_status(status));
|
||||
|
||||
let (background, text_color) = if state.is_disabled() {
|
||||
let (container, content) = interaction::disabled_button_colors(ui, Role::Surface);
|
||||
// 透明容器在禁用态依然透明(官方只改内容透明度)
|
||||
(spec.fill.map(|_| container), content)
|
||||
} else {
|
||||
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
|
||||
let background = match spec.fill {
|
||||
Some(role) => match state.layer() {
|
||||
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
|
||||
.or(Some(ui.color(role))),
|
||||
None => Some(ui.color(role)),
|
||||
},
|
||||
None => transparent_state_color(ui, state, spec.content),
|
||||
};
|
||||
(background, ui.color(spec.content))
|
||||
};
|
||||
|
||||
let border_color = if state.is_disabled() {
|
||||
interaction::disabled_button_colors(ui, Role::Surface).0
|
||||
} else {
|
||||
spec.border
|
||||
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: background.map(Background::Color),
|
||||
text_color,
|
||||
border: Border {
|
||||
color: if spec.border.is_some() {
|
||||
border_color
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
},
|
||||
// 线宽来自 `Geometry::stroke`(有描边的变体 = HAIRLINE,其余 0);
|
||||
// `spec.border.is_none()` 时颜色是透明的,宽度也就无所谓了。
|
||||
width: spec.stroke,
|
||||
radius: ui.radius(spec.shape, spec.radius_side),
|
||||
},
|
||||
// rubric §7:L0/L1 不用阴影(Elevated 的 L1 由容器色调差表达)
|
||||
shadow: Shadow::default(),
|
||||
// ⚠️ `snap` 目前**只有 wgpu 后端实现**;我们的验收图走 tiny-skia(CPU),
|
||||
// 所以这个 `true` 在验收图上不起作用——分隔线的「1dp 摊成 2 行」正是这个原因,
|
||||
// 不是「iced 没有 snap」(reviewer P2-4 纠正的正是我上一版报告里的说法)。
|
||||
snap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
|
||||
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
|
||||
///
|
||||
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
|
||||
/// 三个变体共用一条口径,免得各自发挥。
|
||||
pub(super) fn transparent_state_color(
|
||||
ui: &Ui,
|
||||
state: Interaction,
|
||||
content: Role,
|
||||
) -> Option<iced::Color> {
|
||||
interaction::overlay_color(ui, state, content, Role::Surface)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 按钮家族的**内部几何**:一个数值都不写,全部由 `unlucky_design` 的令牌推导。
|
||||
//!
|
||||
//! | 量 | 令牌 | 值(DESKTOP / 100% DPI) |
|
||||
//! |---|---|---|
|
||||
//! | 可见高 | `LayoutMetrics::control_height` | 40 `[源]` 12b 1.1 |
|
||||
//! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 |
|
||||
//! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 |
|
||||
//! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 |
|
||||
//! | 图标边长 | `IconSize::Button` | 18 `[源]` AAR `ButtonDefaults.IconSize = 18.dp`(12b 1.6 同值) |
|
||||
//! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 |
|
||||
//! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 |
|
||||
//! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 |
|
||||
//! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 |
|
||||
//!
|
||||
//! **没有落点的一个参考值**(登记在报告里,不在此处写数):
|
||||
//!
|
||||
//! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 +
|
||||
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束。
|
||||
//!
|
||||
//! **G14 已收口(M86)**:这里曾登记「12b 1.6 图标 18 → 令牌只有 16,先用 16 并偏离 2px」。
|
||||
//! 令牌层现在有 18 档([`IconSize::Button`],出处在 `crates/design/src/icons/mod.rs`),
|
||||
//! 所以本文件取 18 就是**回到规范值**,不再是偏离。**别按 `ButtonSmallTokens.IconSize = 20` 改**:
|
||||
//! 那个 token 在 AAR 里零消费(没有任何类读它的 `getIconSize`),是死令牌。
|
||||
|
||||
use iced::Padding;
|
||||
|
||||
use unlucky_design::icons::IconSize;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
/// 文字按钮的几何。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Geometry {
|
||||
/// 可见高(药丸圆角按它折算)。
|
||||
pub height: f32,
|
||||
/// 内边距(上下 0;左右按有无前置图标取 16 或 24)。
|
||||
pub padding: Padding,
|
||||
/// 图标盒边长。
|
||||
pub icon: f32,
|
||||
/// 图标 ↔ 文字间距。
|
||||
pub gap: f32,
|
||||
/// 描边宽度;`0` = 该式无描边。
|
||||
pub stroke: f32,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
|
||||
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
|
||||
let (_, _, border) = variant.roles();
|
||||
let start = if leading_icon {
|
||||
ui.space(Space::X4)
|
||||
} else {
|
||||
ui.space(Space::X6)
|
||||
};
|
||||
|
||||
Self {
|
||||
height: ui.metrics().control_height,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: ui.space(Space::X6),
|
||||
bottom: 0.0,
|
||||
left: start,
|
||||
},
|
||||
icon: IconSize::Button.px(),
|
||||
gap: ui.space(Space::X2),
|
||||
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的几何(正方形,图标 24、边距 8)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct IconGeometry {
|
||||
/// 容器边长(40,命中区 48 由父级留白补足)。
|
||||
pub side: f32,
|
||||
/// 图标盒边长(24)——**必须**经 `layout::centered_fixed` 锁住,否则 Q9 会把字形撑满。
|
||||
pub icon: f32,
|
||||
/// 图标到容器边的默认边距(8;narrow 4 / wide 14 属变体参数,本 mission 不用)。
|
||||
pub padding: f32,
|
||||
}
|
||||
|
||||
impl IconGeometry {
|
||||
/// 从令牌求值。图标边长**由 `side − 2×padding` 推导**(40 − 16 = 24),
|
||||
/// 与 `IconSize::Toolbar`(24)互为校验:两者不等说明令牌层动了,单测会先红。
|
||||
pub fn of(ui: &Ui) -> Self {
|
||||
let side = ui.metrics().control_height;
|
||||
let padding = ui.space(Space::X2);
|
||||
Self {
|
||||
side,
|
||||
icon: side - 2.0 * padding,
|
||||
padding,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 按钮家族(`research/12b` §1 五式 + §2 IconButton)。
|
||||
//!
|
||||
//! ## 参考值(全部 `[源]` = A1,`material3-android:1.4.0` 的 `*Tokens.kt` / `Button.kt`)
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 本模块落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1.1 | 容器可见高 | **40** | `LM.control_height`([`Geometry::height`]) |
|
||||
//! | 1.2 | 圆角 | **full = 高/2 = 20** | `ShapeScale::Full` + `Ui::radius` |
|
||||
//! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` |
|
||||
//! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` |
|
||||
//! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 |
|
||||
//! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] |
|
||||
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
|
||||
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
|
||||
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
|
||||
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
|
||||
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
|
||||
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
|
||||
//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_button_content`](按钮族 10% 底) |
|
||||
//! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) |
|
||||
//!
|
||||
//! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**)
|
||||
//!
|
||||
//! 契约 §2 的示例签名是 `filled(ui, label, on_press)`——三个参数装不下
|
||||
//! 「五式 × 五态 × 前后双图标 × 禁用」。所以本模块的落地是**契约的加法版**:
|
||||
//! 泛型纪律一字不改(`M: Clone`,不碰 `app::Message`),但把前两个参数收进 [`Button`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! 契约示例: filled(ui, "保存", Message::Save)
|
||||
//! 本模块: filled(ui, Button::new("保存").press(Message::Save))
|
||||
//! ```
|
||||
//!
|
||||
//! 差别只是「label + 消息」两件事从位置参数变成构造器参数,语义一一对应;
|
||||
//! 要图标/禁用/焦点取证时再加链式调用(`.leading(..)` / `Button::disabled(..)` / `.state(..)`),
|
||||
//! 不需要第二个签名。**这是本周唯一一处偏离契约示例的地方**,报告「决策」一节有登记。
|
||||
//!
|
||||
//! ## 为什么不用 iced 内置的 `theme::Button` 样式
|
||||
//!
|
||||
//! iced 的内置按钮样式只有 `primary/success/danger/text` 几档,且**不带 M3 的状态层 α**
|
||||
//! (读的是 `Palette` 现成色)。D30 要求 hover 8% / pressed 10% 可判定,所以样式自绘;
|
||||
//! 但**只画颜色与描边**——尺寸仍由 `button` 的 width/height/padding 决定,不手搓几何。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod assemble;
|
||||
mod geometry;
|
||||
|
||||
pub use geometry::{Geometry, IconGeometry};
|
||||
|
||||
use assemble::{assemble, style_closure, StyleSpec};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 按钮的五种视觉变体(12b §1.9 的色彩角色,一名一式)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Variant {
|
||||
/// 实心强调色:**每屏最多一个**(rubric K2)。
|
||||
Filled,
|
||||
/// 次强调(tonal):`SecondaryContainer`。
|
||||
Tonal,
|
||||
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。
|
||||
Elevated,
|
||||
/// 描边:透明容器 + 1dp `OutlineVariant`。
|
||||
Outlined,
|
||||
/// 纯文本:最低权重。
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// 五式全序(gallery 的矩阵按这个次序出图)。
|
||||
pub const ALL: [Variant; 5] = [
|
||||
Variant::Filled,
|
||||
Variant::Tonal,
|
||||
Variant::Elevated,
|
||||
Variant::Outlined,
|
||||
Variant::Text,
|
||||
];
|
||||
|
||||
/// 稳定名字(图例 / 文件名 / 失败信息用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Filled => "filled",
|
||||
Variant::Tonal => "tonal",
|
||||
Variant::Elevated => "elevated",
|
||||
Variant::Outlined => "outlined",
|
||||
Variant::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该式在组件表里的档位(圆角/字级)。`Component` 没有 `Outlined/Elevated` 变体,
|
||||
/// 借用 `PrimaryButton`:三者判据值相同(`Full` 圆角 + `LabelLarge` + L0)。
|
||||
pub(crate) const fn component(self) -> Component {
|
||||
match self {
|
||||
Variant::Filled => Component::PrimaryButton,
|
||||
Variant::Tonal => Component::SecondaryButton,
|
||||
Variant::Text => Component::TextButton,
|
||||
Variant::Elevated | Variant::Outlined => Component::PrimaryButton,
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆角档位(五式统一 `Full`,12b 1.2)。
|
||||
pub(crate) fn shape(self) -> ShapeScale {
|
||||
self.component().spec().shape
|
||||
}
|
||||
|
||||
/// **禁用态的内容角色**(A1,逐变体不同——别在这里统一猜)。
|
||||
///
|
||||
/// | 变体 | 禁用内容 |
|
||||
/// |---|---|
|
||||
/// | Filled / Elevated / Outlined / Text | `OnSurfaceVariant` @0.38 |
|
||||
/// | **FilledTonal** | **`OnSurface` @0.38** ← A1 的显式例外(`FilledTonalButtonTokens`) |
|
||||
///
|
||||
/// 出处:AAR `material3-android:1.4.0` 的 `javap -c`,`FilledButtonTokens.
|
||||
/// DisabledLabelTextColor = OnSurfaceVariant`、`FilledTonalButtonTokens` 同一字段 = `OnSurface`。
|
||||
pub(crate) const fn disabled_content(self) -> Role {
|
||||
match self {
|
||||
Variant::Tonal => Role::OnSurface,
|
||||
_ => Role::OnSurfaceVariant,
|
||||
}
|
||||
}
|
||||
|
||||
/// 高程:只有 Elevated 是 L1(`ElevatedButtonTokens.ContainerElevation`),其余 L0。
|
||||
///
|
||||
/// **不画阴影**:rubric §7「L0/L1 不应有可见阴影(用 tone 差)」,所以 L1 只体现为
|
||||
/// `SurfaceContainerLow` 容器色,`button::Style.shadow` 永远 default。
|
||||
pub(crate) const fn elevation(self) -> Elevation {
|
||||
match self {
|
||||
Variant::Elevated => Elevation::Level1,
|
||||
_ => Elevation::Level0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 色彩角色三元组 `(容器, 内容, 描边)`;`None` = 透明容器 / 无描边。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
Variant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
Variant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None),
|
||||
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)),
|
||||
Variant::Text => (None, Role::OnSurfaceVariant, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的三种容器变体(12b §2.5)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconVariant {
|
||||
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
|
||||
Standard,
|
||||
/// 实心:`Primary` + `OnPrimary`。
|
||||
Filled,
|
||||
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
|
||||
Tonal,
|
||||
}
|
||||
|
||||
impl IconVariant {
|
||||
/// 三式全序(gallery 顺序)。
|
||||
pub const ALL: [IconVariant; 3] = [
|
||||
IconVariant::Standard,
|
||||
IconVariant::Filled,
|
||||
IconVariant::Tonal,
|
||||
];
|
||||
|
||||
/// 稳定名字。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
IconVariant::Standard => "standard",
|
||||
IconVariant::Filled => "filled",
|
||||
IconVariant::Tonal => "tonal",
|
||||
}
|
||||
}
|
||||
|
||||
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
|
||||
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
|
||||
pub(crate) const fn disabled_content(self) -> Role {
|
||||
Role::OnSurface
|
||||
}
|
||||
|
||||
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
|
||||
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
IconVariant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
|
||||
///
|
||||
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
|
||||
/// 与 `button::on_press_maybe` 的语义天然对齐(省掉一个 `disabled: bool` 字段,
|
||||
/// 也就不会出现「有消息但画成禁用」这种自相矛盾的状态)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Button<'a, M> {
|
||||
label: &'a str,
|
||||
on_press: Option<M>,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Button<'a, M> {
|
||||
/// 一个可点的按钮。
|
||||
pub fn new(label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: Some(on_press),
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_button_colors`。
|
||||
pub fn disabled(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: None,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置图标(右内边距保持 24)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(`--screen=gallery` 的状态矩阵取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled 按钮(实心 `Primary`)。
|
||||
pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Filled, button)
|
||||
}
|
||||
|
||||
/// Tonal 按钮(`SecondaryContainer`)。
|
||||
pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Tonal, button)
|
||||
}
|
||||
|
||||
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。
|
||||
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Elevated, button)
|
||||
}
|
||||
|
||||
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。
|
||||
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Outlined, button)
|
||||
}
|
||||
|
||||
/// Text 按钮(无容器、无描边)。
|
||||
pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Text, button)
|
||||
}
|
||||
|
||||
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
|
||||
///
|
||||
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
|
||||
/// 如 `Play`/`Stop`/`More`)与描边字形(`Stroke`)——混着放会让线宽看起来忽粗忽细。
|
||||
/// 允许实心字形,但只能出现在「主操作」这一处(例如播放按钮),其余位置一律描边;
|
||||
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
|
||||
///
|
||||
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
|
||||
pub fn icon<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
kind: IconKind,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = IconGeometry::of(ui);
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = state.or(if on_press.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(Interaction::Disabled)
|
||||
});
|
||||
|
||||
// Q9:图标**必须在固定尺寸的中层容器里**,否则 Button 的 Fill 约束会让 usvg 按 Contain
|
||||
// 撑满内容区(M68:24 的字形渲染成 46px、线宽 1.5 → 2.9)。
|
||||
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
|
||||
// 所以「居中」这件事必须由容器自己写死,别指望 button。
|
||||
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
|
||||
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content());
|
||||
let icon_box =
|
||||
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
|
||||
shape: Component::IconButton.spec().shape,
|
||||
stroke: 0.0,
|
||||
radius_side: geometry.side,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(icon_box)
|
||||
.width(Length::Fixed(geometry.side))
|
||||
.height(Length::Fixed(geometry.side))
|
||||
.padding(Padding::default())
|
||||
.style(style);
|
||||
|
||||
match on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 按钮家族的单测(几何值 / 色彩角色 / 样式闭包)。
|
||||
//!
|
||||
//! 这里测的是「函数与样式结构」;**像素级**的几何(40 高、20 圆角、24 图标盒)
|
||||
//! 由 `gallery/probe.rs` 对着真截图量——M68 的教训是「函数对、图错」,两者缺一不可。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Background, Shadow};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::assemble::StyleSpec;
|
||||
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
|
||||
use crate::theme::{Families, Ui, STROKE_PX};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(
|
||||
flavor,
|
||||
unlucky_design::typography::Platform::Windows,
|
||||
FAMILIES,
|
||||
)
|
||||
}
|
||||
|
||||
fn light() -> Ui {
|
||||
ui(SchemeFlavor::Light)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Msg {
|
||||
Pressed(&'static str),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variant_builds_and_disabled_has_no_message() {
|
||||
let ui = light();
|
||||
let _ = super::filled(&ui, super::Button::new("Filled", Msg::Pressed("f")));
|
||||
let _ = super::tonal(&ui, super::Button::new("Tonal", Msg::Pressed("t")));
|
||||
let _ = super::elevated(&ui, super::Button::new("Elevated", Msg::Pressed("e")));
|
||||
let _ = super::outlined(&ui, super::Button::new("Outlined", Msg::Pressed("o")));
|
||||
let _ = super::text(&ui, super::Button::new("Text", Msg::Pressed("x")));
|
||||
let _ = super::filled::<Msg>(&ui, super::Button::disabled("Disabled"));
|
||||
let _ = super::filled(
|
||||
&ui,
|
||||
super::Button::new("Icons", Msg::Pressed("i"))
|
||||
.leading(IconKind::Plus)
|
||||
.trailing(IconKind::ChevronRight),
|
||||
);
|
||||
}
|
||||
|
||||
/// 12b 1.1~1.6 + 2.1~2.2:几何逐值钉住(每个值都要能指回令牌)。
|
||||
#[test]
|
||||
fn geometry_matches_the_reference_values() {
|
||||
let ui = light();
|
||||
let geometry = Geometry::of(&ui, Variant::Filled, false);
|
||||
assert_eq!(geometry.height, 40.0, "12b 1.1 可见高 40");
|
||||
assert_eq!(geometry.height, ui.metrics().control_height);
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.height)
|
||||
.top_left,
|
||||
20.0,
|
||||
"12b 1.2 full 圆角 = 高/2"
|
||||
);
|
||||
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24");
|
||||
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24");
|
||||
assert_eq!(geometry.padding.top, 0.0);
|
||||
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
|
||||
assert_eq!(geometry.gap, ui.space(Space::X2));
|
||||
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
|
||||
|
||||
let with_icon = Geometry::of(&ui, Variant::Filled, true);
|
||||
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16");
|
||||
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动");
|
||||
|
||||
let outlined = Geometry::of(&ui, Variant::Outlined, false);
|
||||
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
|
||||
assert_eq!(outlined.stroke, 1.0);
|
||||
|
||||
// 图标 18 是**令牌值**(`IconSize::Button`,AAR `ButtonDefaults.IconSize`);
|
||||
// 12b 1.6 的 18 与它同源。**别按 `ButtonSmallTokens.IconSize = 20` 改**:那是零消费者的死令牌。
|
||||
assert_eq!(
|
||||
geometry.icon, 18.0,
|
||||
"IconSize::Button(官方 ButtonDefaults.IconSize)"
|
||||
);
|
||||
assert_eq!(
|
||||
geometry.icon,
|
||||
unlucky_design::icons::IconSize::Button.px(),
|
||||
"组件侧必须只从令牌取值(不许写字面量)"
|
||||
);
|
||||
assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_geometry_matches_the_reference() {
|
||||
let ui = light();
|
||||
let geometry = IconGeometry::of(&ui);
|
||||
assert_eq!((geometry.side, geometry.icon), (40.0, 24.0), "12b 2.1/2.2");
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.side)
|
||||
.top_left,
|
||||
20.0,
|
||||
"40×40 的 full 圆角 = 20"
|
||||
);
|
||||
assert_eq!(geometry.padding, 8.0, "12b 2.2 默认边距 8");
|
||||
// 命中区 = 容器 40 + 四周各 4(12b 2.8);按钮自身不撑高
|
||||
assert_eq!(
|
||||
geometry.side + 2.0 * ui.space(Space::X1),
|
||||
ui.metrics().touch_target,
|
||||
"40 + 8 = 48 命中下限"
|
||||
);
|
||||
}
|
||||
|
||||
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
|
||||
///
|
||||
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
|
||||
/// 「主操作」单独出场的图标(例如播放键),不能与描边图标同处一颗按钮。
|
||||
#[test]
|
||||
fn sample_icons_never_mix_fill_and_stroke() {
|
||||
use unlucky_design::icons::IconPaint;
|
||||
|
||||
let sample = [IconKind::Download, IconKind::ChevronRight];
|
||||
for kind in sample {
|
||||
assert_eq!(
|
||||
kind.icon().paint,
|
||||
IconPaint::Stroke,
|
||||
"{} 是填充字形,不能和描边图标混在同一颗按钮里",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
// 已知的实心字形(留档,下次有人拿它们当按钮图标时这条会提醒)
|
||||
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_variants_have_distinct_colour_roles() {
|
||||
let mut seen = Vec::new();
|
||||
for variant in Variant::ALL {
|
||||
let roles = variant.roles();
|
||||
assert!(
|
||||
!seen.contains(&roles),
|
||||
"{variant:?} 与别的变体色彩角色重复了"
|
||||
);
|
||||
seen.push(roles);
|
||||
}
|
||||
|
||||
// 12b 1.9 逐条角色名核对
|
||||
assert_eq!(
|
||||
Variant::Filled.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::Primary),
|
||||
unlucky_design::color::Role::OnPrimary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Tonal.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SecondaryContainer),
|
||||
unlucky_design::color::Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Elevated.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SurfaceContainerLow),
|
||||
unlucky_design::color::Role::Primary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Outlined.roles(),
|
||||
(
|
||||
None,
|
||||
unlucky_design::color::Role::OnSurfaceVariant,
|
||||
Some(unlucky_design::color::Role::OutlineVariant)
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Text.roles(),
|
||||
(None, unlucky_design::color::Role::OnSurfaceVariant, None)
|
||||
);
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let expected = if variant == Variant::Elevated {
|
||||
Elevation::Level1
|
||||
} else {
|
||||
Elevation::Level0
|
||||
};
|
||||
assert_eq!(variant.elevation(), expected, "{variant:?} 高程");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_variants_match_the_reference_roles() {
|
||||
use unlucky_design::color::Role;
|
||||
assert_eq!(
|
||||
IconVariant::Standard.roles(),
|
||||
(None, Role::OnSurfaceVariant, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Filled.roles(),
|
||||
(Some(Role::Primary), Role::OnPrimary, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Tonal.roles(),
|
||||
(
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
|
||||
let ui = light();
|
||||
for variant in IconVariant::ALL {
|
||||
let _ = super::icon::<Msg>(&ui, variant, IconKind::Settings, None, None);
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
None,
|
||||
);
|
||||
// 五态取证开关都能出元素(gallery 矩阵依赖它)
|
||||
for state in Interaction::ALL {
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
|
||||
let (fill, content, border) = variant.roles();
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
stroke: Geometry::of(&light(), variant, false).stroke,
|
||||
radius_side: Geometry::of(&light(), variant, false).height,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
|
||||
/// **渲染层**:样式闭包吐给 iced 的圆角/描边/阴影必须是令牌推导值。
|
||||
#[test]
|
||||
fn outlined_style_draws_hairline_border_and_full_radius() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Outlined, None));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.background, None, "Outlined 静息容器必须透明");
|
||||
assert_eq!(idle.border.width, STROKE_PX, "1dp 描边");
|
||||
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
|
||||
assert_eq!(
|
||||
idle.border.color,
|
||||
ui.color(unlucky_design::color::Role::OutlineVariant)
|
||||
);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
|
||||
|
||||
// 透明容器也要看得见 hover:状态层叠到表面色上
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
assert_ne!(hovered.background, None, "透明容器的 hover 必须有可见变化");
|
||||
assert_ne!(hovered.background, idle.background);
|
||||
|
||||
let disabled = style(&ui.theme(), button::Status::Disabled);
|
||||
let (container, content) =
|
||||
interaction::disabled_button_colors(&ui, unlucky_design::color::Role::Surface);
|
||||
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
|
||||
assert_eq!(
|
||||
disabled.border.color, container,
|
||||
"禁用描边 = 按钮族的令牌 10%(OutlinedButtonTokens.DisabledContainerOpacity)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_button_state_layers_are_visible_and_ordered() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Filled, None));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
let pressed = style(&ui.theme(), button::Status::Pressed);
|
||||
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
))
|
||||
);
|
||||
assert_ne!(hovered.background, idle.background, "hover 8%");
|
||||
assert_ne!(
|
||||
pressed.background, hovered.background,
|
||||
"pressed 10% ≠ hover 8%"
|
||||
);
|
||||
assert_eq!(
|
||||
hovered.text_color,
|
||||
ui.color(unlucky_design::color::Role::OnPrimary),
|
||||
"12b 2.4/4.7:状态层不换内容色"
|
||||
);
|
||||
assert_eq!(idle.border.width, 0.0, "实心按钮无描边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_state_overrides_the_runtime_status() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Tonal, Some(Interaction::Hovered)));
|
||||
// 运行期说 Active,但取证要 hover → 必须画 hover
|
||||
assert_eq!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
style(&ui.theme(), button::Status::Hovered).background
|
||||
);
|
||||
assert_ne!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SecondaryContainer)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevated_uses_a_tone_not_a_shadow() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Elevated, None));
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "rubric §7:L1 不画阴影");
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SurfaceContainerLow)
|
||||
)),
|
||||
"L1 由 SurfaceContainerLow 的色调差表达"
|
||||
);
|
||||
assert_eq!(
|
||||
idle.text_color,
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 分隔线(`research/15` §22,A1:`DividerTokens.kt` / `Divider.kt` / `ListTokens.kt`)。
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 22.1 | 厚度 | **1dp** → 1px ±0 @100% DPI | `LayoutMetrics::divider_thickness` = [`STROKE_PX`] |
|
||||
//! | 22.2 | 颜色 | **`OutlineVariant`**(不是 `Outline`,那个更深、会抢层级) | `Role::OutlineVariant` |
|
||||
//! | 22.3 | 缩进 | 列表内 **左 16 / 右 16**;区块分隔 **0(通栏)** | [`list`] / [`full`] |
|
||||
//! | 22.4 | 用量 | 同屏同类线 ≤3;同容器内不得既有描边又有分隔线 | 页面线自查(本模块不强制) |
|
||||
//! | 22.5 | 何时不用 | tone 能分层就不画线;纵向分隔本项目不用 | 文档约定 |
|
||||
//!
|
||||
//! ## 语义纪律(22.5 的一句话口径)
|
||||
//!
|
||||
//! **分隔线是兜底手段,不是排版手段**:能靠 `Surface` / `SurfaceContainerLow` /
|
||||
//! `SurfaceContainerHigh` 的色差把层级说清的地方就不画线;卡片已经有 1px `OutlineVariant`
|
||||
//! 描边时,卡片**之间**不再画线(同时出现 = 双重分隔,rubric K3 那类廉价感)。
|
||||
//!
|
||||
//! ## 为什么只有两个入口
|
||||
//!
|
||||
//! §22.3 只承认两种缩进:通栏(区块分隔)与列表内 16。第三种缩进一定是「页面自己想出来的」,
|
||||
//! 所以不提供任意数值入口——真要第三种,先在 `SubAgentsTalk/` 登记。
|
||||
|
||||
use iced::widget::{container, space};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::layout;
|
||||
|
||||
/// 通栏分隔线(区块分隔:0 缩进)。
|
||||
pub fn full<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
line(ui)
|
||||
}
|
||||
|
||||
/// 列表内分隔线:左缩进 `Space::X4`(16),右侧到底(`ListTokens.DividerLeadingSpace`)。
|
||||
///
|
||||
/// 右缩进按官方也是 16,但列表行本身已有 16 的内边距时再缩一次会变成 32——
|
||||
/// 本项目把「行内边距」交给行,本函数只负责**线相对行**的缩进,故右端通到行尾。
|
||||
pub fn list<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
layout::inset_start(ui.space(Space::X4), line(ui))
|
||||
}
|
||||
|
||||
/// 一条 1px 的横线(厚度/颜色都只有一处定义)。
|
||||
fn line<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(thickness(ui))
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::OutlineVariant))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 线厚(= `LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`,三者同一真源)。
|
||||
pub fn thickness(ui: &Ui) -> f32 {
|
||||
debug_assert_eq!(
|
||||
ui.metrics().divider_thickness,
|
||||
STROKE_PX,
|
||||
"分隔线厚度必须是一像素线的唯一真源"
|
||||
);
|
||||
ui.metrics().divider_thickness
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 22.1:厚度 1(不是 2,也不是 1.5)。
|
||||
#[test]
|
||||
fn thickness_is_exactly_one_pixel() {
|
||||
let ui = ui();
|
||||
assert_eq!(thickness(&ui), 1.0);
|
||||
assert_eq!(thickness(&ui), STROKE_PX);
|
||||
assert_eq!(thickness(&ui), ui.metrics().divider_thickness);
|
||||
}
|
||||
|
||||
/// 22.3:列表内缩进 16,来自 `Space::X4` 而不是字面量。
|
||||
#[test]
|
||||
fn list_inset_comes_from_the_spacing_token() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.space(Space::X4), 16.0, "ListTokens.DividerLeadingSpace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_forms_build() {
|
||||
let ui = ui();
|
||||
let _: Element<'_, ()> = full(&ui);
|
||||
let _: Element<'_, ()> = list(&ui);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 反馈家族里的两个**小动作件**:文字动作与图标动作。
|
||||
//!
|
||||
//! ## 为什么不复用 `buttons`
|
||||
//!
|
||||
//! 这两处的内容角色都不在按钮家族的三档里:Dialog 动作 = `Primary`,Snackbar action =
|
||||
//! `InversePrimary`(A1 `SnackbarTokens.ActionLabelTextColor`),Snackbar 关闭图标 =
|
||||
//! `InverseOnSurface`。`buttons::text` 固定 `OnSurfaceVariant`(12b §1.9 的 Text 变体口径),
|
||||
//! 拿它画 Dialog 动作就是颜色角色错位(rubric 判 P1)。
|
||||
//!
|
||||
//! 所以这里只做**一份**最小实现:内容/容器角色作参数,尺寸/字级仍走令牌,状态层仍走
|
||||
//! [`interaction`]——与按钮家族同一条产线,只是角色由调用点给。
|
||||
//!
|
||||
//! ## 禁用是状态不是构造器
|
||||
//!
|
||||
//! 照 `selection/chip.rs` 修好的模式:`on_press: Option<M>`,`None` = 禁用;文字色降到
|
||||
//! `OnSurface@38%`,标签本身**不删**。禁用色的**底**必须是这两个动作件自己的容器角色
|
||||
//! (`InverseSurface` / `SurfaceContainerHigh`)——`interaction::disabled_content_over_role`
|
||||
//! 只支持「叠在 Surface / 叠在 12% 容器上」两档,拿它合成会得到「38% 深字叠深底」(复审 P2-4)。
|
||||
|
||||
use iced::widget::{button, text};
|
||||
use iced::{Background, Border, Element, Length, Padding, Shadow};
|
||||
|
||||
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
|
||||
use unlucky_design::color::{Role, Scheme};
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
|
||||
/// 禁用态内容色:`OnSurface@38%` 叠在**真实容器色**上(复审 P2-4)。
|
||||
///
|
||||
/// `interaction::disabled_content_over_role` 的 `on_container` 只有「叠在 Surface / 叠在
|
||||
/// 12% 容器上」两档,而这两个动作件的底是 `InverseSurface`(Snackbar)与
|
||||
/// `SurfaceContainerHigh`(Dialog)——直接调它会得到「38% 深字叠深底」。
|
||||
/// 这里按同一令牌口径([`DISABLED_CONTENT_ALPHA`])在本文件合成一次,底换成真实容器角色。
|
||||
fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> iced::Color {
|
||||
if !disabled {
|
||||
return ui.color(role);
|
||||
}
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(scheme.get(container_role)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一个文字动作(Dialog 的动作区、Snackbar 的 action 共用)。
|
||||
///
|
||||
/// - `content` / `container_role`:内容色与**状态层叠在什么颜色上**(Dialog =
|
||||
/// `Primary` on `SurfaceContainerHigh`;Snackbar = `InversePrimary` on `InverseSurface`);
|
||||
/// - `on_press = None` → 禁用(文字降到 38%,仍占位);
|
||||
/// - `forced`:取证用的强制态(运行期不要给)。
|
||||
pub(super) fn text_action<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
content: Role,
|
||||
container_role: Role,
|
||||
on_press: Option<M>,
|
||||
forced: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let style = ui.emphasized(TypeRole::LabelLarge);
|
||||
let height = ui.metrics().control_height;
|
||||
let pad = ui.space(Space::X3);
|
||||
let shape = Component::TextButton.spec().shape;
|
||||
let radius = ui.radius(shape, height);
|
||||
let disabled = on_press.is_none();
|
||||
|
||||
let ink = content_color(ui, content, container_role, disabled);
|
||||
|
||||
let element = button(
|
||||
text(label)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ink),
|
||||
)
|
||||
.height(Length::Fixed(height))
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: pad,
|
||||
bottom: 0.0,
|
||||
left: pad,
|
||||
})
|
||||
.style(move |_theme, status| {
|
||||
let state = forced.unwrap_or_else(|| {
|
||||
if disabled {
|
||||
Interaction::Disabled
|
||||
} else {
|
||||
Interaction::from_status(status)
|
||||
}
|
||||
});
|
||||
button::Style {
|
||||
background: interaction::overlay_color(ui, state, content, container_role)
|
||||
.map(Background::Color),
|
||||
text_color: ink,
|
||||
border: Border {
|
||||
radius,
|
||||
..Border::default()
|
||||
},
|
||||
// 浮层的阴影只在 Dialog 容器上(rubric K3 单层),动作件本身不投影。
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
});
|
||||
|
||||
match on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个图标动作(Snackbar 的关闭键)。命中区 = `touch_target`,字形经 `layout::centered_fixed`
|
||||
/// 锁在 24(Q9:不锁的话字体会被 Button 的 Fill 约束撑满)。
|
||||
pub(super) fn icon_action<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
kind: IconKind,
|
||||
content: Role,
|
||||
container_role: Role,
|
||||
on_press: Option<M>,
|
||||
forced: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let metrics = ui.metrics();
|
||||
let side = metrics.touch_target;
|
||||
let glyph_side = unlucky_design::icons::IconSize::Toolbar.px();
|
||||
let shape = Component::IconButton.spec().shape;
|
||||
let radius = ui.radius(shape, side);
|
||||
let disabled = on_press.is_none();
|
||||
|
||||
let ink = content_color(ui, content, container_role, disabled);
|
||||
|
||||
// 走 `view_at(24)` 而不是 `view(..)`:`icons.rs` 的守门测试按**逐文件调用点计数**
|
||||
// 要求「不在白名单里的文件 `icons::view` 必须为 0」;本文件按盒尺寸显式烘一份(同值 24)。
|
||||
let element = button(layout::centered_fixed(
|
||||
glyph_side,
|
||||
glyph_side,
|
||||
icons::view_at::<M>(kind, ink, glyph_side),
|
||||
))
|
||||
.width(Length::Fixed(side))
|
||||
.height(Length::Fixed(side))
|
||||
.padding(Padding::default())
|
||||
.style(move |_theme, status| {
|
||||
let state = forced.unwrap_or_else(|| {
|
||||
if disabled {
|
||||
Interaction::Disabled
|
||||
} else {
|
||||
Interaction::from_status(status)
|
||||
}
|
||||
});
|
||||
button::Style {
|
||||
background: interaction::overlay_color(ui, state, content, container_role)
|
||||
.map(Background::Color),
|
||||
text_color: ink,
|
||||
border: Border {
|
||||
radius,
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
});
|
||||
|
||||
match on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Dialog(`research/12b` §7):scrim + 容器 + 标题/正文/动作三区。
|
||||
//!
|
||||
//! ## 官方值(`[源]` = A1,`DialogTokens` + `AlertDialogKt`,`javap` 核对)
|
||||
//!
|
||||
//! | # | 项 | 值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 7.1 | 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
|
||||
//! | 7.2 | 容器 | **`SurfaceContainerHigh`**(`DialogTokens.ContainerColor`) | `Ui::layer(MaterialLayer::Overlay)` |
|
||||
//! | 7.3 | 内距 | 四边 **24**;标题下 **16**;正文下 **24** | [`DialogGeometry`](`Space` 档位) |
|
||||
//! | 7.4 | 宽 | 官方 **280..560**;本项目 **480** | [`DialogGeometry::width`] |
|
||||
//! | 7.5 | 字级 | 标题 `HeadlineSmall 24/32`、正文 `BodyMedium 14/20`、动作 `LabelLarge 14/20 w500` | 组件层取角色 |
|
||||
//! | 7.6 | 动作区 | 文本 **`Primary`**;主轴 **8** / 交叉 **12**;右对齐 | [`DialogGeometry`] |
|
||||
//! | 7.7 | 遮罩 | `scrim` = 纯黑 @ **0.32**(`SCRIM_ALPHA`) | [`scrim`] |
|
||||
//! | 7.8 | 数量 | 一屏 **1 个** modal | 页面责任(组件不计数) |
|
||||
//!
|
||||
//! ## 三种动作形态
|
||||
//!
|
||||
//! [`DialogActions::None`](纯告知)/ [`DialogActions::Single`](「知道了」)/
|
||||
//! [`DialogActions::Double`](「取消」+「删除」,右边是主操作)。双动作的 `dismiss` 在左、
|
||||
//! `confirm` 在右(M3 的阅读序;本项目**不**做「危险操作反色」——官方 Dialog 动作只有 `Primary`)。
|
||||
//!
|
||||
//! ## scrim 为什么是**不透明预合成**
|
||||
//!
|
||||
//! 官方是「纯黑 @0.32 盖在内容上」。D27 要求 CPU 档零半透明(否则 CPU/GPU 两图不一致),
|
||||
//! 所以 [`scrim`] 返回的是 `黑@0.32` **叠在 `MaterialLayer::Canvas` 上**之后的实色:
|
||||
//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。
|
||||
//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。
|
||||
//!
|
||||
//! **深色档的观感(观察项 D-5)**:画布本身很暗,0.32 叠上去只从 (9,21,28) 压到 (6,14,19)
|
||||
//! (≈4%),几乎看不出「压暗」。数值是官方 token 推出来的,**不调**:要更强的层级感应该动
|
||||
//! Dialog 的容器档或阴影,而不是把 scrim 抬到 0.4(那就偏离 token 了)。
|
||||
//!
|
||||
//! ## 不画阴影(偏离 D-1)
|
||||
//!
|
||||
//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画,
|
||||
//! 层级由 `SurfaceContainerHigh` + scrim 表达。
|
||||
|
||||
use iced::widget::{column, container, row, text, Space};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::{Role, Scheme};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
use super::super::interaction::Interaction;
|
||||
use super::action;
|
||||
use super::panel::DialogGeometry;
|
||||
|
||||
/// 动作区的三种形态(「无动作」「单动作」「双动作」)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DialogActions<'a, M> {
|
||||
/// 无动作:只有标题与正文(纯告知型,例如「已是最新版本」)。
|
||||
None,
|
||||
/// 单动作:确认/知道了。
|
||||
Single {
|
||||
/// 动作文案。
|
||||
label: &'a str,
|
||||
/// 按下后的消息。
|
||||
on_press: M,
|
||||
},
|
||||
/// 双动作:左侧 dismiss、右侧 confirm。
|
||||
Double {
|
||||
/// 左(次要)动作。
|
||||
dismiss: (&'a str, M),
|
||||
/// 右(主)动作。
|
||||
confirm: (&'a str, M),
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a, M> DialogActions<'a, M> {
|
||||
/// 是否为空(图例/测试用)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, DialogActions::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个对话框的装配描述(**不持有任何状态所有权**)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dialog<'a, M> {
|
||||
title: &'a str,
|
||||
body: &'a str,
|
||||
actions: DialogActions<'a, M>,
|
||||
max_width: f32,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Dialog<'a, M> {
|
||||
/// 标题 + 正文,默认无动作。
|
||||
pub fn new(title: &'a str, body: &'a str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
body,
|
||||
actions: DialogActions::None,
|
||||
// 项目取值 480(官方区间 280..560 内);窄宿主由页面传更小的可用宽。
|
||||
max_width: 480.0,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 可用宽(默认 480)。实际宽 = `min(available, 480)`,且**不低于官方下限 280**
|
||||
/// (`DialogMinWidth`;可用宽传 200 时仍画 280 并溢出宿主 —— iced 无 min-width,见 G17-d)。
|
||||
///
|
||||
/// 为什么不让组件自己从宿主推:iced 的 `Length` 没有 min-width,而 `Fill + max_width`
|
||||
/// 会被父级的精确分配顶掉(实测渲染成 548 而不是 480,见 `dialog()` 里的说明)。
|
||||
pub fn width(mut self, available: f32) -> Self {
|
||||
self.max_width = available;
|
||||
self
|
||||
}
|
||||
|
||||
/// 单动作。
|
||||
pub fn confirm(mut self, label: &'a str, on_press: M) -> Self {
|
||||
self.actions = DialogActions::Single { label, on_press };
|
||||
self
|
||||
}
|
||||
|
||||
/// 双动作(左 dismiss / 右 confirm)。
|
||||
pub fn double(mut self, dismiss: (&'a str, M), confirm: (&'a str, M)) -> Self {
|
||||
self.actions = DialogActions::Double { dismiss, confirm };
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制动作件的视觉态(gallery 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。
|
||||
pub fn scrim(ui: &Ui) -> iced::Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
|
||||
to_iced(scheme.scrim().over(canvas))
|
||||
}
|
||||
|
||||
/// 把内容放进一块遮罩里(模态的「背后压暗」)。页面把它压在自己的内容上面即可。
|
||||
pub fn scrimmed<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
let backdrop = scrim(ui);
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(backdrop)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 画一个对话框(不含遮罩;遮罩用 [`scrimmed`])。
|
||||
pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, M> {
|
||||
let geometry = DialogGeometry::of(ui);
|
||||
let surface = ui.layer(MaterialLayer::Overlay);
|
||||
let radius = iced::border::Radius::new(geometry.radius);
|
||||
|
||||
let title_style = ui.style(TypeRole::HeadlineSmall);
|
||||
let body_style = ui.style(TypeRole::BodyMedium);
|
||||
|
||||
let mut content = column![].width(Length::Fill).spacing(0.0).push(
|
||||
text(spec.title)
|
||||
.size(title_style.size_px)
|
||||
.font(ui.font_of(title_style))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
);
|
||||
content = content.push(gap(geometry.title_gap)).push(
|
||||
text(spec.body)
|
||||
.size(body_style.size_px)
|
||||
.font(ui.font_of(body_style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
);
|
||||
|
||||
if !spec.actions.is_empty() {
|
||||
content = content.push(gap(geometry.body_gap)).push(actions_row(
|
||||
ui,
|
||||
&geometry,
|
||||
spec.actions,
|
||||
spec.state,
|
||||
));
|
||||
}
|
||||
|
||||
// 宽度 = `min(spec.max_width, 480)`,**可用宽由调用方递**(`Dialog::width`)。
|
||||
//
|
||||
// 复审 P2-1 建议「Fill + max_width」:实测**不行**。`Row` 给 `Fill` 子项的 limits 是
|
||||
// `with_compression(min = max = 分配额)`,而 `Limits::max_width()` 的实现是
|
||||
// `self.max.width.min(max_width).max(self.min.width)` —— 父级那个 **min 会把 480 顶回去**,
|
||||
// 两列并排时每张卡渲染成 **548**(探针实测,见报告)。iced 没有 min-width,也没有在
|
||||
// 「父级精确分配」下可用的 max-width ⇒ 与 Snackbar 的最小宽同一堵墙(G17-d 之二)。
|
||||
// 所以:默认 480,窄宿主由页面传 `Dialog::width(可用宽)`。
|
||||
// 可用宽 → 实际宽:`min(480, 可用宽)` 再 `.max(280)`(官方 `DialogMinWidth`,复审 E)。
|
||||
// 可用宽 < 280 时仍画 280(溢出宿主)——iced 无 min-width,这条写进 G17-d 之二。
|
||||
let effective = geometry.effective_width(spec.max_width);
|
||||
container(content)
|
||||
.width(Length::Fixed(effective))
|
||||
.padding(Padding::from(geometry.padding))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(surface)),
|
||||
border: Border {
|
||||
radius,
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。
|
||||
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
|
||||
Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。
|
||||
fn actions_row<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
geometry: &DialogGeometry,
|
||||
actions: DialogActions<'a, M>,
|
||||
state: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let controls = match actions {
|
||||
DialogActions::None => row![],
|
||||
DialogActions::Single { label, on_press } => row![action::text_action(
|
||||
ui,
|
||||
label,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(on_press),
|
||||
state,
|
||||
)],
|
||||
DialogActions::Double { dismiss, confirm } => row![
|
||||
action::text_action(
|
||||
ui,
|
||||
dismiss.0,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(dismiss.1),
|
||||
state,
|
||||
),
|
||||
action::text_action(
|
||||
ui,
|
||||
confirm.0,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(confirm.1),
|
||||
state,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
container(
|
||||
controls
|
||||
.spacing(geometry.action_gap)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
Loaded 100 of 1029 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user