mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d784ed6d6 | ||
|
|
3524a9fb6a | ||
|
|
16a07336fa | ||
|
|
372acad69d | ||
|
|
0694a186ae | ||
|
|
690681194a | ||
|
|
e5f5b75801 | ||
|
|
ca121eaf9b | ||
|
|
90effbe6b2 | ||
|
|
0d9e678ea8 | ||
|
|
e0ca9a4779 | ||
|
|
47831de6bf | ||
|
|
1345212f38 | ||
|
|
34ebef5524 | ||
|
|
b661446471 | ||
|
|
ddedaffdf0 | ||
|
|
0c45db5968 | ||
|
|
399e232141 | ||
|
|
108f41e16d | ||
|
|
5418317cf8 | ||
|
|
ce7460d071 | ||
|
|
cda2915da4 | ||
|
|
845edced30 | ||
|
|
4636880a69 | ||
|
|
6dd6a23388 | ||
|
|
de12671c0f | ||
|
|
d519e83100 | ||
|
|
abf8ec53b2 | ||
|
|
2fa2487e0d | ||
|
|
d6b5e34bc8 | ||
|
|
94dc1671c1 | ||
|
|
75f2d3c38f | ||
|
|
a7bf4b1205 | ||
|
|
9ed45e1e9f | ||
|
|
69400e57ac | ||
|
|
adb7bf8cc7 | ||
|
|
b3523730f5 | ||
|
|
922c1bfe85 | ||
|
|
a5e966e98e | ||
|
|
f690a0537c | ||
|
|
ffe680d3f5 | ||
|
|
b9a87f8bda | ||
|
|
5247d0acdc | ||
|
|
1ff55e8026 | ||
|
|
e788d83765 | ||
|
|
fd9c4e44dd | ||
|
|
9d34d5ecab | ||
|
|
f8c262da03 | ||
|
|
be0305d665 | ||
|
|
f5a442c133 | ||
|
|
7d15f23c63 | ||
|
|
4bee13505c | ||
|
|
ce60d21610 | ||
|
|
4db0f505d0 | ||
|
|
bc6f415d98 | ||
|
|
5d985236c2 | ||
|
|
99fe3dbbf1 | ||
|
|
d7fa4c3b0f | ||
|
|
f1003c74a0 | ||
|
|
98ebd85258 | ||
|
|
ee27c844d7 | ||
|
|
e591109795 | ||
|
|
612f4ad550 | ||
|
|
7668609b08 | ||
|
|
717baf5ecd | ||
|
|
efdbf04694 | ||
|
|
7d75708986 | ||
|
|
33730cb13c | ||
|
|
258269c30c | ||
|
|
212388b682 | ||
|
|
2af6ef5422 | ||
|
|
cc6c305649 | ||
|
|
351c1fa9a3 | ||
|
|
ebca87f6ec | ||
|
|
c7715a4a7a | ||
|
|
d9424dacdc | ||
|
|
c812fbc2e5 | ||
|
|
59fc67f43a | ||
|
|
fdee835f9e | ||
|
|
ecbbbcf428 | ||
|
|
38620addcc | ||
|
|
106ee3670a | ||
|
|
8f137304cf | ||
|
|
20e300871f | ||
|
|
2688a5b34b | ||
|
|
ebe1858ad2 | ||
|
|
6de37f872e | ||
|
|
b3e683ad75 | ||
|
|
a12e814476 | ||
|
|
5c7fc811a1 | ||
|
|
c587f9e7dc | ||
|
|
d2bf651d53 | ||
|
|
64501d34bf | ||
|
|
ce2a950525 | ||
|
|
f207c88732 | ||
|
|
e89d7be726 | ||
|
|
dc0274076d | ||
|
|
24f9b73cd7 | ||
|
|
3892810d1d | ||
|
|
d33f88bba5 | ||
|
|
dcaa53ba07 | ||
|
|
faa0d5bc6e | ||
|
|
0423298c95 | ||
|
|
2e5e4f9bec | ||
|
|
71f648e2b6 | ||
|
|
3df18bdb5f | ||
|
|
b9db827dd4 | ||
|
|
acd58cdcaf | ||
|
|
7f60b15571 | ||
|
|
e6ac8c9add | ||
|
|
0633035468 | ||
|
|
7b10eefb3d | ||
|
|
298bb73476 | ||
|
|
dbf2764c14 | ||
|
|
9702283b4d | ||
|
|
61e43ef64d | ||
|
|
be15d7a6d0 | ||
|
|
bbcf3dff69 | ||
|
|
72e4c58b63 | ||
|
|
c88d079723 | ||
|
|
c044e821c0 | ||
|
|
4f7b9fd280 | ||
|
|
aeb46aeb4e | ||
|
|
4c10ec72de | ||
|
|
32132d3bf2 | ||
|
|
8bd7dccc14 | ||
|
|
3dad9ceb74 | ||
|
|
c34c9c31b4 | ||
|
|
312e7e9247 | ||
|
|
a22ee52eaa | ||
|
|
06eb4c34a6 | ||
|
|
1bd4ddfe38 | ||
|
|
6ca9b32695 | ||
|
|
7a92d0cb30 | ||
|
|
30d5487c71 | ||
|
|
73e4709340 | ||
|
|
9fe33e78b3 | ||
|
|
80c2b8dea6 | ||
|
|
877f021c16 | ||
|
|
37f320a4ef | ||
|
|
7959dfe77e | ||
|
|
60f28d0405 | ||
|
|
0f119c9c5c | ||
|
|
5436dc9ae1 | ||
|
|
574f69bdde | ||
|
|
3dc9249204 | ||
|
|
6e2647b8f6 | ||
|
|
5eb6691ae0 | ||
|
|
9be7ea6cbc | ||
|
|
7b13eaef56 | ||
|
|
9eea85f892 | ||
|
|
4ac6a344a8 | ||
|
|
2701be6327 | ||
|
|
478131f826 | ||
|
|
566ff0121b | ||
|
|
4ce75ea335 | ||
|
|
e7f55a8415 | ||
|
|
cc5d129293 | ||
|
|
18e2a47e19 | ||
|
|
751a697589 | ||
|
|
4a4718c7bf | ||
|
|
431a87652e | ||
|
|
fb99d78c33 | ||
|
|
186b2ee2df | ||
|
|
79597b19db | ||
|
|
7a1113d5e6 | ||
|
|
426b16f95a | ||
|
|
e6f70622b5 | ||
|
|
f757912b74 | ||
|
|
c0b0770a4a | ||
|
|
e7ce2480c3 | ||
|
|
af6d91a9bb | ||
|
|
cd2a779d3a | ||
|
|
cdf8fad699 | ||
|
|
b1d5f81459 | ||
|
|
a6eeef486b | ||
|
|
9da4afb553 | ||
|
|
a318c609b2 | ||
|
|
da5e098968 | ||
|
|
3bd031f4ea | ||
|
|
820e25e333 | ||
|
|
a88cb60824 | ||
|
|
ab30b0c994 | ||
|
|
829ca39c79 | ||
|
|
44761d3444 | ||
|
|
04d6f39378 | ||
|
|
d257e677c3 | ||
|
|
5d4e335e9e | ||
|
|
5ae84e2b89 | ||
|
|
16de6cfdde | ||
|
|
e0c8ac0723 | ||
|
|
ebca63af9f | ||
|
|
47c44e9298 | ||
|
|
f4cdcba372 | ||
|
|
1c6550f75f | ||
|
|
b0e85b06cf | ||
|
|
2a8b0c4f03 | ||
|
|
33b017c710 | ||
|
|
32a641107f | ||
|
|
49b3aa4d53 | ||
|
|
2b47ae1fbd | ||
|
|
55ab4e8d92 | ||
|
|
5f8eae49ec | ||
|
|
078359533b | ||
|
|
8761026022 | ||
|
|
62814aae7c | ||
|
|
ebbddbac5a | ||
|
|
f5867f5103 | ||
|
|
b98b4f7201 | ||
|
|
c5e6a5c3ae | ||
|
|
b1a67e195d | ||
|
|
4309227e31 | ||
|
|
2bd6a19565 | ||
|
|
b522bd0b8b | ||
|
|
65d6442012 | ||
|
|
5910c148f8 | ||
|
|
d0be627024 | ||
|
|
732ec45bfe | ||
|
|
fcc9363a17 | ||
|
|
39a1e8b432 | ||
|
|
c184d41602 | ||
|
|
0d92555c24 | ||
|
|
96c3da197f | ||
|
|
d1eeef5318 | ||
|
|
f4d88a3142 | ||
|
|
4c192248d5 | ||
|
|
abf44246c5 | ||
|
|
1dd0b8a247 | ||
|
|
ffd136f463 | ||
|
|
c1a243d46c | ||
|
|
31ba7705ed | ||
|
|
b5a79cda33 | ||
|
|
903aa09cdc | ||
|
|
a3ba9e4f5c | ||
|
|
fb3a65146a | ||
|
|
634f8c77d4 | ||
|
|
51446c4d27 | ||
|
|
9a0e636c3d | ||
|
|
40e06a52e2 | ||
|
|
6d47f6c837 | ||
|
|
12f018ffc5 | ||
|
|
f289e3903f | ||
|
|
40d577d9f6 | ||
|
|
2234daf25f | ||
|
|
1d81b5b361 | ||
|
|
561f385778 | ||
|
|
ac7c495553 | ||
|
|
af03a12941 | ||
|
|
35011ad3d4 | ||
|
|
60bee4b979 | ||
|
|
a125fbc9b2 | ||
|
|
d2d610d65b | ||
|
|
7931e701ec | ||
|
|
6343236907 | ||
|
|
b520c59678 | ||
|
|
3b9d7b3609 | ||
|
|
6487ed6f21 | ||
|
|
fc23415309 | ||
|
|
1c23c7dcd6 | ||
|
|
4f2c5a2212 | ||
|
|
36de07d2fa | ||
|
|
91686f3c3f | ||
|
|
1ef2f9168d | ||
|
|
9d05547a00 | ||
|
|
82312cad7c | ||
|
|
6cd5d1a849 | ||
|
|
a572624a6a | ||
|
|
8e65a19dde | ||
|
|
8730e31eab | ||
|
|
b678e56a32 | ||
|
|
7da9815f14 | ||
|
|
a24430df38 | ||
|
|
d97edf1631 | ||
|
|
1e4c8bd9c4 | ||
|
|
ec668a1264 | ||
|
|
b1db7d8338 | ||
|
|
f2187c9d03 | ||
|
|
2367cd4d65 | ||
|
|
fa22360007 | ||
|
|
e2bdbc322f | ||
|
|
43ba7f3b45 | ||
|
|
5207b65161 | ||
|
|
8d7a0189a6 | ||
|
|
ba2fb2e3e7 | ||
|
|
ef0e34ccc4 | ||
|
|
a32fe7f492 | ||
|
|
46b22cf79b | ||
|
|
f9d08b4811 | ||
|
|
6f9643f976 | ||
|
|
ec91de1853 | ||
|
|
ab4dfc827a | ||
|
|
a3c43490fb | ||
|
|
783e56578b | ||
|
|
cc0635b7c0 | ||
|
|
ab7ded2e46 | ||
|
|
e033f1bb11 | ||
|
|
f5b89e246f | ||
|
|
1d76e29525 | ||
|
|
9c44a56bbf | ||
|
|
2d7cda8ef8 | ||
|
|
2f78502847 | ||
|
|
75552a6902 | ||
|
|
6fb3c3b65a | ||
|
|
3228d69968 | ||
|
|
6a814cdc7c | ||
|
|
5dfb9a3eca | ||
|
|
eb7dd41e8f | ||
|
|
844aa7b04e | ||
|
|
1c0c8e7d8a | ||
|
|
80e3680595 | ||
|
|
a3420a00f2 | ||
|
|
4a78b32113 | ||
|
|
5a25c18574 | ||
|
|
04329b5355 | ||
|
|
78c872fcf7 | ||
|
|
706c2a16a4 | ||
|
|
29ffc3d278 | ||
|
|
4af61e3d92 | ||
|
|
d72bc50a8f | ||
|
|
3903638ded | ||
|
|
68f01019f4 | ||
|
|
3a2a39528a | ||
|
|
fad9794bc9 | ||
|
|
0a4908854f | ||
|
|
584eb2eadc | ||
|
|
8cb659ef4a | ||
|
|
940e4f017e | ||
|
|
7c7846d76c | ||
|
|
a8b15e1f04 | ||
|
|
407b9bd0c1 | ||
|
|
9f7b4d1104 | ||
|
|
824bb2faad | ||
|
|
e6db67a14d | ||
|
|
8954d0302d | ||
|
|
f5ee8d785d | ||
|
|
0d07d95c6c | ||
|
|
7641b1e249 | ||
|
|
5186562d29 | ||
|
|
30d1638a52 | ||
|
|
5bcc7a4599 | ||
|
|
87fd186f8f | ||
|
|
0ba5ac1f09 | ||
|
|
0dbc09fc1c | ||
|
|
da8d7944da | ||
|
|
fd311693f6 | ||
|
|
3f0463e2bf | ||
|
|
0ac031b168 | ||
|
|
e4af6f2be7 | ||
|
|
2baa0a1e19 | ||
|
|
cc73202dda | ||
|
|
878b983b67 | ||
|
|
1597e64b40 | ||
|
|
9432f13c4f | ||
|
|
2211e22552 | ||
|
|
1cc5648b65 | ||
|
|
2e842b987a | ||
|
|
0874955cff | ||
|
|
de2809cf64 | ||
|
|
7df54d6f91 | ||
|
|
502e8f045c | ||
|
|
d924132cc7 | ||
|
|
0a999b6857 | ||
|
|
c1e2840bab | ||
|
|
3a623c3ced | ||
|
|
b8f3ae549e | ||
|
|
dae22d82ce | ||
|
|
0133febcf6 | ||
|
|
2d9f6fd634 | ||
|
|
a2147d0474 | ||
|
|
db6c6a233b | ||
|
|
aa7090472e | ||
|
|
86c63d9ac6 | ||
|
|
de6b70a6fd | ||
|
|
c791b02d14 | ||
|
|
44695d6106 | ||
|
|
d98a908641 | ||
|
|
88129d0f6a | ||
|
|
b84dfcc5cf | ||
|
|
698a963ab2 | ||
|
|
25b996c08f | ||
|
|
01161ca789 | ||
|
|
b795c20c93 | ||
|
|
2f9207b408 | ||
|
|
efa78dfd49 | ||
|
|
5b15cd35d0 | ||
|
|
3ef990601f | ||
|
|
56378d2f33 | ||
|
|
d5a9618ffd | ||
|
|
df95cc8535 | ||
|
|
357d8079a2 | ||
|
|
05d65a05ed | ||
|
|
b575889b2f | ||
|
|
5f667467e8 | ||
|
|
3bcbbf3dbc | ||
|
|
3b2b1f6e32 | ||
|
|
230bf814c7 | ||
|
|
d00f905d4e | ||
|
|
f290a6377a | ||
|
|
a05b31f489 | ||
|
|
33b725f56c | ||
|
|
6fee91ef4e | ||
|
|
9e6e1d1e28 | ||
|
|
e47cb489b7 | ||
|
|
6b14a90f88 | ||
|
|
ced22c2265 | ||
|
|
13e58f3ae8 | ||
|
|
810156ad6e | ||
|
|
f4acb67608 | ||
|
|
3a402a47d6 | ||
|
|
b5b7cf49e6 | ||
|
|
70025d2bf7 | ||
|
|
68801c9d0b | ||
|
|
80ea09baa3 | ||
|
|
5ba9a614f0 | ||
|
|
2d2988e92f | ||
|
|
a5eab68235 | ||
|
|
472f1a89ca | ||
|
|
2f2919b55b | ||
|
|
9e306466b0 | ||
|
|
02a9021e85 | ||
|
|
e0d4357c7c | ||
|
|
1a0e08e79f | ||
|
|
0876ccc422 | ||
|
|
9bda2b6328 | ||
|
|
8d406040e3 | ||
|
|
e41eaa3720 | ||
|
|
ece6e8d45f | ||
|
|
4240e6cbf7 | ||
|
|
94cac74614 | ||
|
|
98712b569b | ||
|
|
0b3698342f | ||
|
|
fc0a5c71f8 | ||
|
|
0261d13269 | ||
|
|
9b91f76517 | ||
|
|
5c1869a2c1 | ||
|
|
b22f8f5f44 | ||
|
|
950a85f20b | ||
|
|
e55b9e40e6 | ||
|
|
0636552ae3 | ||
|
|
0f9c2195fb | ||
|
|
132696f9eb | ||
|
|
dd498e42c2 | ||
|
|
90f6ccff18 | ||
|
|
429765e490 | ||
|
|
ace76d7fe2 | ||
|
|
c027688879 | ||
|
|
43a4bb1775 | ||
|
|
22cfb53ac9 | ||
|
|
660e5a2e82 | ||
|
|
40e097879d | ||
|
|
d097836293 | ||
|
|
c6cf583607 | ||
|
|
14f3d5c24c | ||
|
|
aebcc9ed26 | ||
|
|
a41ce9f980 | ||
|
|
9696ae040c | ||
|
|
6114a4f7ec | ||
|
|
d1cc96d1c6 | ||
|
|
1f2e9c9687 | ||
|
|
669720b3fc | ||
|
|
7a1b699780 | ||
|
|
181b0d8eed | ||
|
|
3897e34dc2 | ||
|
|
294da726f7 | ||
|
|
6896ae1040 | ||
|
|
f88827ef93 | ||
|
|
388ea158dd | ||
|
|
09ce7f22c6 | ||
|
|
14c37fbc46 | ||
|
|
dd7fe3cc0d | ||
|
|
835977ec08 | ||
|
|
ac006a6711 | ||
|
|
3f00870fda | ||
|
|
7a112879d7 | ||
|
|
54702a85d9 | ||
|
|
f8e8d3add1 | ||
|
|
d6b8820faf | ||
|
|
7fdaf4ed2c | ||
|
|
6c9ba53a92 | ||
|
|
0531f88217 | ||
|
|
5c9e8cdb11 | ||
|
|
ccfe034d0a | ||
|
|
05a9bba1a0 | ||
|
|
8ec2b41318 | ||
|
|
6811ad08c9 | ||
|
|
5626e1fccb | ||
|
|
9aa914a05c | ||
|
|
c7c15b1942 | ||
|
|
4e6207173e | ||
|
|
8f010c8dce | ||
|
|
fc59f10772 | ||
|
|
00aa05ccab | ||
|
|
922fed0f32 | ||
|
|
6348150ee7 | ||
|
|
1c24d30e41 | ||
|
|
1409a8ee35 | ||
|
|
df0d334dc5 | ||
|
|
2679dcf7f6 | ||
|
|
3b8a9fcda5 | ||
|
|
f00d90f4b5 | ||
|
|
4f5a609bc9 | ||
|
|
53774b03ef | ||
|
|
2705b57f2c | ||
|
|
3804d9f156 | ||
|
|
568b1792d7 | ||
|
|
bd30cf6a5c | ||
|
|
d36858a9b1 | ||
|
|
d460758e7c | ||
|
|
406e70378a | ||
|
|
84c6690c61 | ||
|
|
cb2552e5ae | ||
|
|
861e35c3c6 | ||
|
|
ab951a1bbf | ||
|
|
4d999069aa | ||
|
|
d5fb91f607 | ||
|
|
4041b48a8e | ||
|
|
11b8ceb38d | ||
|
|
1faa3c1bbd | ||
|
|
422715f257 | ||
|
|
40dd5cd9b2 | ||
|
|
fbd8d975b2 | ||
|
|
3e0b3a8aeb | ||
|
|
90035ae541 | ||
|
|
a838930ad6 | ||
|
|
6f7f0f8b78 | ||
|
|
6e0e7f37cb | ||
|
|
98395aafe4 | ||
|
|
d15c8d5c89 | ||
|
|
37760c66d3 | ||
|
|
f1a5eb9ebb | ||
|
|
3f3e3c9278 | ||
|
|
44e6f6adba | ||
|
|
1b4792e8b0 | ||
|
|
2a951af216 | ||
|
|
5aeafaf9fa | ||
|
|
d0ec5b3d0b | ||
|
|
c48ee0f80d | ||
|
|
10e929f3d5 | ||
|
|
a750f83859 | ||
|
|
ff77cb75ff | ||
|
|
780d5e04fa | ||
|
|
b4060aa4fd | ||
|
|
e66ef9f65a | ||
|
|
ca39c4b7b3 | ||
|
|
d0c057dbd9 | ||
|
|
ce84fe9ff8 | ||
|
|
03aeb27e4e | ||
|
|
ea8f6c9ca6 | ||
|
|
217702190c | ||
|
|
71164ebf3c | ||
|
|
057894edec | ||
|
|
9f78a77e1c | ||
|
|
f9548ec3dd | ||
|
|
1b6929aad4 | ||
|
|
cad221e1d6 | ||
|
|
458fbbcabf | ||
|
|
d68d34c811 | ||
|
|
510915ba51 | ||
|
|
6db92a8904 | ||
|
|
586b3068c4 | ||
|
|
e3985f1971 | ||
|
|
d9884c795c | ||
|
|
6cc59ec78c | ||
|
|
fbcc83cdc6 | ||
|
|
174571c2a9 | ||
|
|
d1f096b206 | ||
|
|
3bce33bff3 | ||
|
|
2f27112578 | ||
|
|
a2ae61b9c0 | ||
|
|
17589e87d5 | ||
|
|
0818a41038 | ||
|
|
4473c23aa5 | ||
|
|
0a647e12ff | ||
|
|
f43cc2af32 | ||
|
|
101e82b5bb | ||
|
|
21219f6890 | ||
|
|
02a8abeec3 | ||
|
|
7fbcc02b92 | ||
|
|
17af885a67 | ||
|
|
a81212d745 | ||
|
|
cf1ca1ff06 | ||
|
|
7ee7a7fe8e | ||
|
|
c430ae5fe3 | ||
|
|
06ff93fda2 | ||
|
|
9ea570231a | ||
|
|
ba06cdd16f | ||
|
|
470fcfea8b | ||
|
|
79f5f1f90b | ||
|
|
5a1e3655d4 | ||
|
|
c58f236dd8 | ||
|
|
50014383fb | ||
|
|
47056a93ee | ||
|
|
6d98fbe7dd | ||
|
|
4395969694 | ||
|
|
d48ba5e65b | ||
|
|
8931d3e612 | ||
|
|
c51e648adf | ||
|
|
6dd1650dca | ||
|
|
0ee194932c | ||
|
|
bcfbe73bc0 | ||
|
|
06009af71b | ||
|
|
a0a0763fdd | ||
|
|
9ea250b5bf | ||
|
|
68eaa5069e | ||
|
|
139554c91a | ||
|
|
6a30c3e3c6 | ||
|
|
0461622f70 | ||
|
|
67da7b1930 | ||
|
|
d5b7dd6809 | ||
|
|
4357242a98 | ||
|
|
26768e54d6 | ||
|
|
8734ee8d7a | ||
|
|
38e66576fc | ||
|
|
49d8c158bc | ||
|
|
931dda7061 | ||
|
|
abc23f9fea | ||
|
|
0626f197ff | ||
|
|
ee85dd3481 | ||
|
|
75910feefc | ||
|
|
4f548643b0 | ||
|
|
113a01e1dc | ||
|
|
9a26301d83 | ||
|
|
2e2b580bd8 | ||
|
|
fa818864ee | ||
|
|
c67fc94eb9 | ||
|
|
7e71b2dce4 | ||
|
|
2140e55491 | ||
|
|
7c93b587bf | ||
|
|
53a9bb6dc1 | ||
|
|
0fbcd55ebd | ||
|
|
fa278aedd5 | ||
|
|
567816d7d3 | ||
|
|
cfa255cc70 | ||
|
|
459bc7f4f7 | ||
|
|
be06337aa0 | ||
|
|
d21d91f6b3 | ||
|
|
6766cc0042 | ||
|
|
abbd487b95 | ||
|
|
62ddce6804 | ||
|
|
f9e3f3a5e5 | ||
|
|
a3518cc60a | ||
|
|
b92becac71 | ||
|
|
91ceafc038 | ||
|
|
8cb7d82c01 | ||
|
|
1682ab7fad | ||
|
|
a4f1729c22 | ||
|
|
0ea0c0e0fc | ||
|
|
dfe972034d | ||
|
|
d7b42cc072 | ||
|
|
95822af03a | ||
|
|
a1e598fee9 | ||
|
|
2ea1d8e01c | ||
|
|
2b97f139d8 | ||
|
|
9c371349a1 | ||
|
|
3478a1dc6e | ||
|
|
55d3ca45c1 | ||
|
|
fddc6512a8 | ||
|
|
5fb8925ca2 | ||
|
|
c8d2bac6fb | ||
|
|
f14e75f9b5 | ||
|
|
5b4f8f073a | ||
|
|
7340ac3a68 | ||
|
|
22491271c0 | ||
|
|
191abea93c | ||
|
|
2e46375e4f | ||
|
|
75a08f128c | ||
|
|
cbd9038b07 | ||
|
|
fe7c6c44e6 | ||
|
|
2874af8b0e | ||
|
|
6b9e50246a | ||
|
|
091c1b2865 | ||
|
|
bf0505e70e | ||
|
|
5851b802ad | ||
|
|
970c91d138 | ||
|
|
859ef4782c | ||
|
|
7f79763026 | ||
|
|
6d98474fdf | ||
|
|
8d1f46cb50 | ||
|
|
58aef50d47 | ||
|
|
f3287f7dfc | ||
|
|
25c6d4c10d | ||
|
|
00d7ece65a | ||
|
|
fba020d9bb | ||
|
|
4f4b43978b | ||
|
|
e67f256d5b | ||
|
|
ee40a3b90e | ||
|
|
0968731518 | ||
|
|
3b131d66a4 | ||
|
|
b71837c1e5 | ||
|
|
b88596a4fc | ||
|
|
9290b3bc0d | ||
|
|
fb77904a9f | ||
|
|
0522cd92e6 | ||
|
|
50e25025a0 | ||
|
|
0c9d2455be | ||
|
|
dc921776c3 | ||
|
|
912659f0dd | ||
|
|
4ef274d02e | ||
|
|
79af107700 | ||
|
|
c6a134cfa1 | ||
|
|
97b21b6ed7 | ||
|
|
8e9227ceff | ||
|
|
3b1a88db12 | ||
|
|
900aa3aa3a | ||
|
|
d18b248992 | ||
|
|
a6facc0adf | ||
|
|
c9d1517846 | ||
|
|
2e2c9248f5 | ||
|
|
36d69bf9e1 | ||
|
|
8a4281abfb | ||
|
|
268ccef0a8 | ||
|
|
9eb14cc8cc | ||
|
|
59b2b75e62 | ||
|
|
1724a154f8 | ||
|
|
4c3eabc0ab | ||
|
|
4a0b3888cb | ||
|
|
6f29d3d960 | ||
|
|
b7ee3207da | ||
|
|
80552ff2ff | ||
|
|
f808837d43 | ||
|
|
f08973d82e | ||
|
|
b69d956bc3 | ||
|
|
163828b644 | ||
|
|
953c592d74 | ||
|
|
a201497806 | ||
|
|
92339c8580 | ||
|
|
1ca349830d | ||
|
|
beb1b0082e | ||
|
|
a88eec08d1 | ||
|
|
76552fb5a4 | ||
|
|
beeb2865f0 | ||
|
|
ded403ab37 | ||
|
|
1d3267a44c | ||
|
|
95de31ed20 | ||
|
|
9add93a143 | ||
|
|
1c1c18bd39 | ||
|
|
46efe8eba4 | ||
|
|
4ee29685ef | ||
|
|
783524cfac | ||
|
|
248071332a | ||
|
|
8b5b05a8e4 | ||
|
|
e976667e4f | ||
|
|
6646758aad | ||
|
|
8d5655f96d | ||
|
|
afa0a33442 | ||
|
|
2ca8e60a01 | ||
|
|
ac3673ff72 | ||
|
|
7613fd5d3e | ||
|
|
172d61bba6 | ||
|
|
0d85a11055 | ||
|
|
29fb53efca | ||
|
|
1919833c48 | ||
|
|
58ab25039e | ||
|
|
36c3719cb4 | ||
|
|
fde3581c08 | ||
|
|
9357a0ae36 | ||
|
|
c21474751a | ||
|
|
987f44cafa | ||
|
|
0eb5a7ca2c | ||
|
|
c32b46c9fd | ||
|
|
51f3100546 | ||
|
|
eb3f823174 | ||
|
|
8aed0bc8ab | ||
|
|
467707ce4b | ||
|
|
c69e53e528 | ||
|
|
de06daf75a | ||
|
|
fd6b917c80 | ||
|
|
dce96d1c5c | ||
|
|
22ca8f3f17 | ||
|
|
bb7d51e7a9 | ||
|
|
8c644fa972 | ||
|
|
6545e66e36 | ||
|
|
504101cb17 | ||
|
|
2a383830b6 | ||
|
|
119dbd72ce | ||
|
|
f11cd2226b | ||
|
|
2b23801457 | ||
|
|
1e0043d817 | ||
|
|
69a046fed3 | ||
|
|
ec6ffb4887 | ||
|
|
f5dc84673a | ||
|
|
c8abba049a | ||
|
|
645abda947 | ||
|
|
4881f167dc | ||
|
|
bb760c6684 | ||
|
|
c756ff26e7 | ||
|
|
78069f638a | ||
|
|
f54da89d15 | ||
|
|
0b76ca0f63 |
No files matched your search
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -515,6 +515,7 @@ jobs:
|
||||
run: |
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
-ignore 'label "ubuntu-22.04-arm" is unknown' \
|
||||
.github/workflows/release-studio.yml \
|
||||
.github/workflows/studio-certum-signing-smoke.yml \
|
||||
.github/workflows/ci.yml
|
||||
|
||||
@@ -175,6 +175,9 @@ jobs:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
artifact: studio-build-linux-amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
artifact: studio-build-linux-arm64
|
||||
steps:
|
||||
# Real-time scanning inspects every file the job writes, and packaging
|
||||
# writes a quarter of a gigabyte twice. The runner is thrown away after
|
||||
@@ -216,7 +219,16 @@ jobs:
|
||||
# not this shell's.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
|
||||
|
||||
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
|
||||
# runner the .deb and .rpm are built with one installed from RubyGems.
|
||||
- name: Use a native fpm on ARM64 Linux
|
||||
if: runner.os == 'Linux' && runner.arch == 'ARM64'
|
||||
run: |
|
||||
sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm -v 1.16.0
|
||||
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
|
||||
|
||||
# The same Developer ID and notary credentials the desktop line uses: one
|
||||
# certificate signs any bundle identifier its team owns, so Studio does not
|
||||
@@ -360,6 +372,23 @@ jobs:
|
||||
pnpm exec electron-builder --prepackaged "../../$app" $arch \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
|
||||
- name: Verify both Linux package formats
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-$arch.deb" "dist/ReasonixStudio-linux-$arch.rpm" "$arch"
|
||||
|
||||
- name: Accept the installed Linux package
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
|
||||
|
||||
# With signing on, this leg only hands the bundle on: the signing jobs
|
||||
# run nothing but the signer, and windows-package packages in between.
|
||||
- name: Upload the unsigned Windows bundle for signing
|
||||
@@ -986,6 +1015,11 @@ jobs:
|
||||
name: studio-build-linux-amd64
|
||||
path: staging/studio-build-linux-amd64
|
||||
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-linux-arm64
|
||||
path: staging/studio-build-linux-arm64
|
||||
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
@@ -1068,34 +1102,7 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 10 ]; then
|
||||
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
# platform: dpkg on Linux, the next installer on Windows, and the
|
||||
# bundle swapped out of the archive on macOS. Missing one degrades the
|
||||
# release silently instead of failing it — it strands that platform on
|
||||
# the version it is running.
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# the version it is running with nothing saying so.
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
bash scripts/verify-studio-artifacts.sh dist
|
||||
|
||||
# The same minisign key the desktop line uses, because the public key that
|
||||
# verifies it is compiled into the client (update.PublicKey).
|
||||
@@ -1183,7 +1190,7 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n\n' '---'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
|
||||
@@ -136,9 +136,30 @@ jobs:
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/firstscreen-fonts.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/stickyfold.mjs
|
||||
node perf/crumb.mjs
|
||||
node perf/hoverradius.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/sessquick.mjs
|
||||
node perf/sessionrows.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/branch-menu.mjs
|
||||
node perf/image-preview.mjs
|
||||
node perf/toast-anchor.mjs
|
||||
node perf/addfolder-rest.mjs
|
||||
node perf/scroll-jump.mjs
|
||||
node perf/add-project-selects.mjs
|
||||
node perf/setup-defer.mjs
|
||||
node perf/search-toggle.mjs
|
||||
node perf/narrow.mjs
|
||||
node perf/settings-rows.mjs
|
||||
node perf/narrow-steps.mjs
|
||||
node perf/narrow-rail.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
@@ -180,6 +201,31 @@ jobs:
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go test ./...
|
||||
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: CUA process and packaging contracts
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 2
|
||||
run: node --test --test-name-pattern=CUA electron/test/unit.mjs
|
||||
|
||||
- name: Install Linux native CUA fixture dependencies
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb openbox dbus-x11 libgtk-3-dev at-spi2-core
|
||||
|
||||
- name: Native CUA application acceptance
|
||||
if: env.RUN_STEPS == 'true'
|
||||
timeout-minutes: 6
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
dbus-run-session -- xvfb-run -a sh -c 'openbox >/tmp/reasonix-openbox.log 2>&1 & export NO_AT_BRIDGE=0 GTK_MODULES=gail:atk-bridge; node electron/test/cua-native.mjs'
|
||||
else
|
||||
node electron/test/cua-native.mjs
|
||||
fi
|
||||
|
||||
# The host sits in the main module and nothing else builds it, so its
|
||||
# dependency closure is checked here: it must stay CGO-free and
|
||||
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
|
||||
@@ -189,7 +235,9 @@ jobs:
|
||||
working-directory: .
|
||||
run: |
|
||||
for os in windows linux darwin; do
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
for arch in amd64 arm64; do
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
done
|
||||
done
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
@@ -219,7 +267,7 @@ jobs:
|
||||
arm64=false
|
||||
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
@@ -231,7 +279,7 @@ jobs:
|
||||
if [ "$platform" = false ]; then
|
||||
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
|
||||
elif [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"},{"os":"ubuntu-22.04-arm","arch":"arm64","platform":"linux"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
@@ -281,7 +329,16 @@ jobs:
|
||||
# to rpmbuild for the .rpm.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
|
||||
|
||||
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
|
||||
# runner the .deb and .rpm are built with one installed from RubyGems.
|
||||
- name: Use a native fpm on ARM64 Linux
|
||||
if: runner.os == 'Linux' && runner.arch == 'ARM64'
|
||||
run: |
|
||||
sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm -v 1.16.0
|
||||
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build the SPA
|
||||
working-directory: desktop/frontend-next
|
||||
@@ -294,7 +351,8 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
REASONIX_VERSION: v0.0.0-ci
|
||||
run: pnpm install --frozen-lockfile && pnpm test && pnpm run build:host
|
||||
|
||||
# The .deb's absolute-path payload. A no-op off Linux, so it is not
|
||||
# guarded here: the one place it must not be forgotten is the one place
|
||||
@@ -310,7 +368,7 @@ jobs:
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }} -c.extraMetadata.version=0.0.0-ci
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -445,82 +503,13 @@ jobs:
|
||||
echo "page: ${page:-<missing>}"
|
||||
test -n "$host" -a -n "$page"
|
||||
|
||||
# Read off the package rather than off the config that describes it. Both
|
||||
# of these went wrong the same way once: electron-builder names a package
|
||||
# after package.json unless told, and fpm records the owner it finds on
|
||||
# disk. Either one leaves a Studio that cannot update itself on Linux and
|
||||
# says nothing about it until a user asks.
|
||||
- name: The .deb is Studio's own package and carries what updates it
|
||||
- name: Linux packages carry their declared architecture and update payload
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
deb=$(ls dist/ReasonixStudio-linux-*.deb)
|
||||
name=$(dpkg-deb -f "$deb" Package)
|
||||
if [ "$name" != "reasonix-studio" ]; then
|
||||
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
|
||||
exit 1
|
||||
fi
|
||||
# The helper in the package accepts one package name, compiled in. It has
|
||||
# to be the one the package carries, or every in-app update is refused
|
||||
# as a different package after the download and signature succeed.
|
||||
dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
|
||||
chmod +x "$RUNNER_TEMP/update-helper"
|
||||
accepts=$("$RUNNER_TEMP/update-helper" package)
|
||||
if [ "$accepts" != "$name" ]; then
|
||||
echo "::error::the update helper in the package accepts $accepts but the package is $name"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -c "$deb" > contents.txt
|
||||
# The path is the polkit action's exec.path annotation, so it is the
|
||||
# only one authorization is granted for; root because polkit will not
|
||||
# run a helper the invoking user could have written. Either spelling
|
||||
# of root: dpkg-deb prints the numeric ids it cannot map, and which
|
||||
# one it prints is a property of the machine reading the package
|
||||
# rather than of the package.
|
||||
if ! grep -qE '^-rwxr-xr-x (root/root|0/0) +[0-9]+ .* \./usr/lib/reasonix-studio/reasonix-studio-update-helper$' contents.txt; then
|
||||
echo "::error::no root-owned executable update helper at the path the polkit action names"
|
||||
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q './usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
|
||||
echo "::error::the polkit action is not in the package, so pkexec has nothing to authorize against"
|
||||
exit 1
|
||||
fi
|
||||
run: bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "dist/ReasonixStudio-linux-${{ matrix.arch }}.rpm" "${{ matrix.arch }}"
|
||||
|
||||
# The .rpm is read back for the same two properties as the .deb: it is the
|
||||
# package update.StudioLine names, and the helper pkexec runs as root is
|
||||
# owned by root and writable by no one else, or any local user could
|
||||
# replace it.
|
||||
- name: The .rpm is Studio's own package and carries what updates it
|
||||
- name: The Linux package installs, runs, reinstalls and uninstalls
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
|
||||
name=$(rpm -qp --qf '%{NAME}' "$rpm")
|
||||
if [ "$name" != "reasonix-studio" ]; then
|
||||
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
|
||||
exit 1
|
||||
fi
|
||||
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
|
||||
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
|
||||
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
|
||||
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
|
||||
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
|
||||
grep polkit contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The Electron shell sets the package's glibc floor; a Go binary linked
|
||||
# with cgo would raise it to the runner's own.
|
||||
- name: The .deb's Go binaries are statically linked
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
deb=$(ls dist/ReasonixStudio-linux-*.deb)
|
||||
dpkg-deb -x "$deb" "$RUNNER_TEMP/deb"
|
||||
host=$(find "$RUNNER_TEMP/deb" -type f -path '*/resources/bin/reasonix-studio-host')
|
||||
test -n "$host"
|
||||
node desktop/electron/packaging/elf.js "$host" "$RUNNER_TEMP/deb/usr/lib/reasonix-studio/reasonix-studio-update-helper"
|
||||
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
|
||||
|
||||
# Recorded, not asserted: the real bundle layout, so later work argues
|
||||
# from what electron-builder produced rather than from its documentation.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Withdraw a Studio version
|
||||
|
||||
# Pulls a published Studio release out of the update catalog (or puts it back),
|
||||
# so installs stop being offered it without waiting for a new release. Only the
|
||||
# catalog changes: the release's files stay where they are, and a withdrawn
|
||||
# release can be restored with the same run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to withdraw or restore, for example 2.36.0"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
|
||||
required: false
|
||||
type: string
|
||||
restore:
|
||||
description: "Put a withdrawn version back instead of withdrawing it"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: studio-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: studio
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update the Studio catalog
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
REASON: ${{ inputs.reason }}
|
||||
RESTORE: ${{ inputs.restore }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "::error::version must look like 2.36.0"
|
||||
exit 1
|
||||
fi
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
current=/tmp/studio-versions-current.json
|
||||
next=/tmp/studio-versions-next.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
|
||||
if [ "$RESTORE" = "true" ]; then
|
||||
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
|
||||
else
|
||||
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
|
||||
fi
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
|
||||
|
||||
- name: Keep the GitHub release off the latest slot
|
||||
if: ${{ !inputs.restore }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -151,7 +151,7 @@ pre-releases):
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
|
||||
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
|
||||
| Linux | `.deb` | x64 |
|
||||
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
|
||||
Every package ships with a `.minisig` signature, and the release carries
|
||||
`SHA256SUMS`. Once installed, Studio updates itself in place.
|
||||
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+5
-5
@@ -57,7 +57,7 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
@@ -139,7 +139,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
|
||||
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
|
||||
| Linux | `.deb` | x64 |
|
||||
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
|
||||
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
|
||||
应用内自行更新。
|
||||
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchInfo is what the mode itself knows before any run happens. Everything
|
||||
// else in the manifest is derived from the runs that were written, so a figure
|
||||
// in it cannot drift from the artifact beside it.
|
||||
type batchInfo struct {
|
||||
Mode string
|
||||
Planned int
|
||||
Started time.Time
|
||||
Dry bool
|
||||
// Repo is the checkout the batch ran from, captured before the harness
|
||||
// isolates its workspace: git cannot describe a directory that is gone.
|
||||
Repo string
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so a later reader does not have
|
||||
// to reconstruct what the batch was from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GitDirty *bool `json:"git_dirty,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Window int `json:"window"`
|
||||
Generations int `json:"generations"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
UsageReportedRuns int `json:"usage_reported_runs"`
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
TrajectoriesSHA256 string `json:"trajectories_sha256"`
|
||||
}
|
||||
|
||||
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
|
||||
// rather than omitted, so an absent field is never mistaken for a clean tree.
|
||||
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
|
||||
commit = "unknown"
|
||||
if repo == "" {
|
||||
return commit, nil
|
||||
}
|
||||
in := func(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
return cmd.Output()
|
||||
}
|
||||
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := in("status", "--porcelain"); err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
|
||||
}
|
||||
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBatchManifest records the batch beside the runs it describes. It runs
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
reported++
|
||||
requests += m.UsageReportedRequests
|
||||
prompt += m.PromptTokens
|
||||
completion += m.CompletionTokens
|
||||
cacheHit += m.CacheHitTokens
|
||||
cacheMiss += m.CacheMissTokens
|
||||
estimated = estimated || m.UsageEstimated
|
||||
}
|
||||
}
|
||||
commit, dirty := gitCommitAndDirty(info.Repo)
|
||||
model := runModel
|
||||
if info.Dry {
|
||||
model = "scripted"
|
||||
}
|
||||
manifest := batchManifest{
|
||||
SchemaVersion: 1, Mode: info.Mode,
|
||||
StartedAt: info.Started.UTC().Format(time.RFC3339),
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
}
|
||||
var err error
|
||||
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, root string) (batchManifest, string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got batchManifest
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, string(data)
|
||||
}
|
||||
|
||||
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{
|
||||
{Task: "b", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
|
||||
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
|
||||
}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := readManifest(t, root)
|
||||
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
|
||||
t.Fatalf("manifest header = %+v", got)
|
||||
}
|
||||
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
|
||||
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
|
||||
}
|
||||
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
|
||||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
|
||||
t.Fatalf("usage totals = %+v", got)
|
||||
}
|
||||
// No repo means git could not describe the checkout: unknown commit, and no
|
||||
// dirty flag at all, because "clean" is a claim this batch cannot make.
|
||||
if got.GitCommit != "unknown" || got.GitDirty != nil {
|
||||
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
|
||||
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
|
||||
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
|
||||
// an unmeasured spend must not read as a measured one.
|
||||
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, raw := readManifest(t, root)
|
||||
if got.UsageReportedRuns != 0 {
|
||||
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
|
||||
}
|
||||
if got.Model != "scripted" {
|
||||
t.Fatalf("dry batch model = %q, want scripted", got.Model)
|
||||
}
|
||||
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
|
||||
if strings.Contains(raw, absent) {
|
||||
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ func main() {
|
||||
case "preflight":
|
||||
os.Exit(runPreflight(tasks, root))
|
||||
case "run-search":
|
||||
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
|
||||
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
|
||||
case "run-boundary":
|
||||
os.Exit(runBoundaries(root))
|
||||
os.Exit(runBoundaries(*mode, root))
|
||||
case "snippet":
|
||||
os.Exit(runSnippetAudit(root, 100))
|
||||
case "adversarial":
|
||||
@@ -69,7 +69,7 @@ func main() {
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -216,7 +216,10 @@ func isolateWorkspace() (func(), error) {
|
||||
|
||||
// The two arms get separate nonces: reusing one would leave the first arm's
|
||||
// answer in a host-readable history for the second to find.
|
||||
func runBoundaries(root string) int {
|
||||
func runBoundaries(mode, root string) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -243,6 +246,7 @@ func runBoundaries(root string) int {
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
name := "index-" + side.scale
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
@@ -260,7 +264,7 @@ func runBoundaries(root string) int {
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
fmt.Print(reportStopping(all))
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
|
||||
}
|
||||
|
||||
// reportDeltas is the paired reading: each task against itself, then pooled.
|
||||
@@ -302,7 +306,10 @@ func deltaBool(a, b bool) string {
|
||||
// runExperiment runs one experiment's tasks across its arms. A dry run drives
|
||||
// the same pipeline with a scripted provider: it proves fixture, run, scoring
|
||||
// and report hold together before any of it is paid for.
|
||||
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -338,6 +345,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
// test: a bad minute would land entirely on one arm.
|
||||
for _, arm := range shuffledArms(t) {
|
||||
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
@@ -359,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
if experiment == experimentIndex {
|
||||
reportBoundaries(all)
|
||||
}
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
|
||||
}
|
||||
|
||||
func cueExpectedVisible(t contextTask, scale string) bool {
|
||||
@@ -414,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResults(root string, all []contextMetrics) int {
|
||||
func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
path := filepath.Join(root, "results.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -432,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
|
||||
if err := writeBatchManifest(root, info, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Desktop surface evaluation
|
||||
|
||||
This protocol supplies the fixed-task trajectory comparison required by
|
||||
[RFC #12584](https://github.com/esengine/DeepSeek-Reasonix/issues/12584).
|
||||
There are no measured results yet. Component tests and scripted providers prove
|
||||
delivery, validation and replay; they do not measure a model's choice of interface
|
||||
or a person's effort.
|
||||
|
||||
## Paired runs
|
||||
|
||||
Run each task twice in fresh desktop sessions: ordinary text/ask interaction and
|
||||
native surfaces. Use the same model, sampling settings, initial workspace,
|
||||
permission mode and task prompt. Record commit, model, configuration and the
|
||||
native-surface capability setting. Alternate condition order across repetitions.
|
||||
Keep the hidden tool out of the provider prefix in both conditions; only its
|
||||
availability through capability discovery differs.
|
||||
|
||||
The baseline may batch questions or accept a single text reply. Never force it to
|
||||
ask one question per field. Do not tell either model how many turns to use or
|
||||
which component to render. If the surface-enabled model chooses text, retain
|
||||
that run in the surface condition. Interface choice is part of the experiment.
|
||||
|
||||
The operator knows the private values below but does not volunteer them before
|
||||
the model requests the corresponding information. Supply all requested values
|
||||
together, regardless of presentation. Allow inspection and local editing before
|
||||
submission. Save the trajectory and an observer ledger locally; raw transcripts
|
||||
and draft content must not enter the aggregate report.
|
||||
|
||||
## Fixed tasks
|
||||
|
||||
| Task | Initial prompt, identical in both conditions | Private operator input | Completion criterion |
|
||||
| --- | --- | --- | --- |
|
||||
| `release-brief` | Prepare a release brief. Ask me for the audience, release date and whether this is a staged rollout before writing it. | audience: existing desktop users; date: 2026-11-03; staged rollout: yes | Final brief contains all three values and adds no release commitment. |
|
||||
| `storage-choice` | Help me choose storage for a small desktop app. Present reasonable choices, ask me to choose and ask whether offline operation is required. Then summarize the decision. | storage: SQLite; offline: yes | Final decision states SQLite and offline operation, with no installation or file changes. |
|
||||
| `draft-revision` | Draft a two-line release announcement and let me revise it before returning the final version verbatim. | Replace with the exact string ` Desktop release ready.\nKeep existing settings.\n` (decode the escapes). | Final returned string has exactly the specified leading spaces and newlines. Formatting wrappers are excluded from the comparison. |
|
||||
| `preview-decision` | Show a plain-text before/after preview changing retry count from 2 to 3. Ask whether to keep that change and whether to include a release note. Return a decision summary; do not change files. | keep change: yes; release note: no | Preview shows 2 to 3; final summary preserves both decisions; workspace is unchanged. |
|
||||
|
||||
Freeze this task set before collecting results. Record a new protocol version if
|
||||
prompts, private inputs, completion criteria or metric definitions change.
|
||||
Neither the model's final claim nor a successful input acknowledgment establishes
|
||||
completion. The observer checks the resulting output against the criterion.
|
||||
|
||||
## Metric definitions (version 1)
|
||||
|
||||
The anchor is acknowledgment of the initial task prompt. The observation ends
|
||||
when the criterion is verified, the operator abandons the task, or the run fails.
|
||||
The initial prompt is excluded from subsequent-input counts because both
|
||||
conditions start from the same request.
|
||||
|
||||
| Metric | Count and population | Exclusions |
|
||||
| --- | --- | --- |
|
||||
| `acknowledged_inputs` | Distinct operator submissions acknowledged after the anchor: chat replies, ask answers, surface answers and surface actions. Includes acknowledged input later withdrawn or superseded. | Initial task prompt, local draft edits, retries with the same acknowledged request identity, automatic dispatch, replay and observer grading. This counts input exchanges, not provider rounds. |
|
||||
| `submission_attempts` | Explicit operator send attempts, including rejected submissions and manual retries. | Automatic transport retries, replay, local edits and model tool calls. |
|
||||
| `withdrawals` | Successful operator withdrawals of admitted input. | Failed withdrawals, automatic cancellation and replay. |
|
||||
| `completed` | Runs whose output meets the fixed criterion. Denominator: every started run in each condition. | None; failures and abandoned runs remain in the denominator. |
|
||||
|
||||
Record these in a content-free ledger: task id, repetition, condition, observer
|
||||
event ordinal, event type, opaque request identity, acknowledgment status and
|
||||
completion verdict. Record the source trajectory reference and observed start
|
||||
and end anchors alongside it. Distinguish manual observation from host-recorded
|
||||
events. Do not copy labels, prompts, private values, drafts or answer strings into
|
||||
the ledger. Identities are local correlation handles, not account identifiers.
|
||||
|
||||
The current pane counters and rendered trajectory export cannot reconstruct all
|
||||
acknowledgments, refusals and withdrawals. Do not infer them from `ask` calls,
|
||||
tool-result counts, final prose, message wording or card mounts. Until structural
|
||||
acknowledgment records cover every channel, an observer ledger is required.
|
||||
|
||||
## Report and decision
|
||||
|
||||
Report the number started, completed, failed and abandoned for each condition,
|
||||
then paired input-count differences for repetitions where **both** conditions
|
||||
meet the same criterion. List excluded pairs and their reason beside that
|
||||
comparison. Also report submission attempts and withdrawals; fewer admitted
|
||||
inputs with more failures is not automatically less effort.
|
||||
|
||||
Keep per-task results visible. Do not combine scripted fixtures with observed
|
||||
model/operator runs or describe this small task set as an account-wide result.
|
||||
Report model/configuration differences and missing evidence as limitations.
|
||||
|
||||
The RFC's benefit remains unproven until observed trajectories show fewer input
|
||||
exchanges to the same verified outcome. These results alone do not justify P3;
|
||||
adaptive layout or MCP Apps needs a measured problem in addition to this result.
|
||||
|
||||
The owner-requested map, comparison and dashboard workspaces extend the delivery
|
||||
scope independently of this study. Their scripted acceptance fixtures verify
|
||||
rendering and interaction only; they do not count as observed benefit evidence.
|
||||
@@ -203,6 +203,11 @@ func answerLeakDetected(r result) bool {
|
||||
}
|
||||
|
||||
func recordTaskTrajectory(r *result, path string, t task) {
|
||||
if r.SFT != nil && r.Segments <= 1 {
|
||||
if err := bindSFTTrajectory(path, r.SFT); err != nil {
|
||||
r.Note = appendNote(r.Note, "SFT provenance: "+err.Error())
|
||||
}
|
||||
}
|
||||
if summary, err := summarizeTrajectory(path); err == nil {
|
||||
r.Trajectory = summary
|
||||
}
|
||||
|
||||
+18
-5
@@ -290,8 +290,8 @@ func renderDiff(r diffReport) string {
|
||||
if r.runErr != nil {
|
||||
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
|
||||
}
|
||||
if r.analysisErr != nil {
|
||||
fmt.Fprintf(&b, "\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
if line := analysisFailureLine(r); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
|
||||
return b.String()
|
||||
@@ -342,9 +342,6 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Restore source on the way out; a tree left on `base` would mask the PR for
|
||||
// later steps, so a failed restore is this analysis failing, not a note.
|
||||
restored := false
|
||||
@@ -359,6 +356,9 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
|
||||
err = terr
|
||||
}
|
||||
}()
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
@@ -681,3 +681,16 @@ func passFail(ok bool) string {
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
|
||||
// table that was already produced still stands, so claiming both are missing
|
||||
// would be wrong whenever only the mutation sweep failed.
|
||||
func analysisFailureLine(r diffReport) string {
|
||||
if r.analysisErr == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.pins) > 0 {
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
|
||||
}
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
}
|
||||
+6
-11
@@ -17,7 +17,6 @@ import (
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
@@ -121,7 +120,8 @@ type sourceUsage struct {
|
||||
type result struct {
|
||||
task
|
||||
runMetrics
|
||||
Profile string `json:"profile"`
|
||||
SFT *sftProvenance `json:"sft,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
// Arm is the ablation arm the harness requested, not the arm the child
|
||||
// reported, so a run that died before writing metrics is still attributable.
|
||||
Arm string `json:"arm"`
|
||||
@@ -518,15 +518,10 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
// then drops in verify.sh and runs it as the grader. The grader is added only
|
||||
// after the run so the agent can't read the answer key.
|
||||
func runTask(cfg suiteConfig, t task) result {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
if cfg.forcePlanner {
|
||||
// The host's own routing marker, so the two-model turn engages without
|
||||
// putting an English sentence in front of every prompt.
|
||||
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
|
||||
r.PlanForced = true
|
||||
t, r, err := prepareRunTask(cfg, t)
|
||||
if err != nil {
|
||||
r.Note = "SFT provenance: " + err.Error()
|
||||
return r
|
||||
}
|
||||
|
||||
// The per-leg file names are decided in runSegments; only the directory has
|
||||
|
||||
+14
-1
@@ -53,6 +53,10 @@ type meterUsage struct {
|
||||
DivergedAt int `json:"diverged_at,omitempty"`
|
||||
Divergence string `json:"divergence,omitempty"`
|
||||
TapeMissing int `json:"tape_missing,omitempty"`
|
||||
// TapeIncomplete counts recorded exchanges that could not be written, so a
|
||||
// recording that cannot be replayed is reported rather than assumed.
|
||||
TapeIncomplete int `json:"tape_incomplete,omitempty"`
|
||||
TapeError string `json:"tape_error,omitempty"`
|
||||
}
|
||||
|
||||
// faultScript decides which requests fail. Absolute indices pin a failure to
|
||||
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
src := io.Reader(resp.Body)
|
||||
if m.tape != nil {
|
||||
src = io.TeeReader(resp.Body, &captured)
|
||||
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
|
||||
defer func() {
|
||||
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
|
||||
m.mu.Lock()
|
||||
m.TapeIncomplete++
|
||||
if m.TapeError == "" {
|
||||
m.TapeError = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
|
||||
m.pipeStream(w, src)
|
||||
|
||||
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// record attaches what the proxy observed. It stays separate from close so the
|
||||
// snapshot is taken while the run's numbers are still being assembled.
|
||||
// record attaches what the proxy observed. It stops the meter first, because a
|
||||
// handler's deferred write can land after the last response is read and stopping
|
||||
// is what waits for those handlers; a snapshot taken before that misses them.
|
||||
func (rm runMeter) record(r *result) {
|
||||
if rm.m == nil {
|
||||
return
|
||||
}
|
||||
rm.close()
|
||||
observed := rm.m.snapshot()
|
||||
r.Meter = &observed
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
|
||||
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
|
||||
b.WriteString(renderMeterAccounting(results))
|
||||
b.WriteString(renderTapeReplay(results))
|
||||
b.WriteString(renderTapeRecord(results))
|
||||
b.WriteString(renderFaultRecovery(results))
|
||||
b.WriteString(renderTimeAttribution(results))
|
||||
b.WriteString(renderSolveProfiles(results))
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Restoring the source is I/O like any other step, but a failure used to read as
|
||||
// success: the analysis went on to report a verdict about a tree that was still
|
||||
// mutated. These helpers make it loud, and are variables so tests can inject one.
|
||||
// Restoring the source is I/O like any other step, and an analysis must not
|
||||
// report a verdict about a tree it failed to restore. These helpers make that
|
||||
// failure loud, and are variables so a test can inject one.
|
||||
|
||||
var (
|
||||
writeFile = os.WriteFile
|
||||
readFile = os.ReadFile
|
||||
// treeMatches is a variable so a test can inject the mismatch a restore guards.
|
||||
treeMatches = treeMatchesTree
|
||||
)
|
||||
|
||||
// restoreSource writes the original bytes back and proves they are back.
|
||||
@@ -34,7 +36,7 @@ func restoreSource(abs string, src []byte) error {
|
||||
|
||||
// treeMatches checks files against a revision. A checkout that exited zero is
|
||||
// not evidence the tree moved; the tree is.
|
||||
func treeMatches(repo, rev string, files []string) error {
|
||||
func treeMatchesTree(repo, rev string, files []string) error {
|
||||
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
|
||||
out, err := exec.Command("git", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -192,3 +192,70 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
t.Fatal("a failed restore still produced pin results")
|
||||
}
|
||||
}
|
||||
|
||||
// The base side is checked before any pin runs, and that check can fail too: the
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "f.go")
|
||||
run("commit", "-qm", "base")
|
||||
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("commit", "-aqm", "head")
|
||||
|
||||
orig := treeMatches
|
||||
treeMatches = func(r, rev string, files []string) error {
|
||||
if rev == base {
|
||||
return errors.New("injected: base side does not match")
|
||||
}
|
||||
return treeMatchesTree(r, rev, files)
|
||||
}
|
||||
t.Cleanup(func() { treeMatches = orig })
|
||||
|
||||
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("a failed base check reported success with %d pins", len(out))
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "head" {
|
||||
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A pin table that was already produced still stands, so the failure line must
|
||||
// not claim both figures are missing when only the mutation sweep failed.
|
||||
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
|
||||
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
|
||||
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
|
||||
t.Fatalf("with pins present the line reads %q", only)
|
||||
}
|
||||
if strings.Contains(only, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with pins present the line still claims both are absent: %q", only)
|
||||
}
|
||||
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
|
||||
if !strings.Contains(both, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with no pins the line reads %q", both)
|
||||
}
|
||||
if analysisFailureLine(diffReport{}) != "" {
|
||||
t.Fatal("a finished analysis produced a failure line")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/session/control"
|
||||
|
||||
func prepareRunTask(cfg suiteConfig, t task) (task, result, error) {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
if cfg.forcePlanner {
|
||||
// The host's own routing marker, so the two-model turn engages without
|
||||
// putting an English sentence in front of every prompt.
|
||||
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
|
||||
r.PlanForced = true
|
||||
}
|
||||
if cfg.trajDir != "" {
|
||||
source, err := newSFTProvenance(t)
|
||||
if err != nil {
|
||||
return t, r, err
|
||||
}
|
||||
r.SFT = source
|
||||
}
|
||||
|
||||
return t, r, nil
|
||||
}
|
||||
+55
-38
@@ -4,8 +4,11 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -44,8 +47,9 @@ type sftToolFunction struct {
|
||||
// from trajectoryRecord because that one deliberately omits tool output: the
|
||||
// analysis passes would otherwise hold every result of every run in memory.
|
||||
type sftRecord struct {
|
||||
RunHeader *sftRunHeader `json:"run_header"`
|
||||
Event *struct {
|
||||
BenchmarkRun *sftProvenance `json:"benchmark_run"`
|
||||
RunHeader *sftRunHeader `json:"run_header"`
|
||||
Event *struct {
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
@@ -162,22 +166,28 @@ func convertSFT(records []sftRecord) []sftMessage {
|
||||
}
|
||||
|
||||
func readSFTRecords(path string) ([]sftRecord, error) {
|
||||
records, _, err := readSFTSnapshot(path)
|
||||
return records, err
|
||||
}
|
||||
|
||||
func readSFTSnapshot(path string) ([]sftRecord, string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []sftRecord
|
||||
sc := bufio.NewScanner(f)
|
||||
h := sha256.New()
|
||||
sc := bufio.NewScanner(io.TeeReader(f, h))
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
|
||||
for sc.Scan() {
|
||||
var rec sftRecord
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
return nil, "", err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, sc.Err()
|
||||
return out, hex.EncodeToString(h.Sum(nil)), sc.Err()
|
||||
}
|
||||
|
||||
// verifyPrefix reports the header and how many of the run's rounds it provably
|
||||
@@ -213,6 +223,10 @@ func buildSFTSample(id, prompt, path string) (*sftSample, string) {
|
||||
if err != nil {
|
||||
return nil, "unreadable: " + err.Error()
|
||||
}
|
||||
return buildSFTSampleRecords(id, prompt, records)
|
||||
}
|
||||
|
||||
func buildSFTSampleRecords(id, prompt string, records []sftRecord) (*sftSample, string) {
|
||||
hdr, covered, total := verifyPrefix(records)
|
||||
if hdr == nil {
|
||||
return nil, "no run header"
|
||||
@@ -267,36 +281,27 @@ func runSFTMode(dir, suite, reportPath, out string) error {
|
||||
return fmt.Errorf("no *.trajectory.jsonl files under %s", dir)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
prompts, err := sftPrompts(suite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
passed, err := sftVerdicts(reportPath)
|
||||
inputs, err := sftInputs(paths, suite, reportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(out)
|
||||
f, err := os.CreateTemp(filepath.Dir(out), ".sft-export-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
buf := bufio.NewWriter(f)
|
||||
enc := json.NewEncoder(buf)
|
||||
kept := 0
|
||||
for _, p := range paths {
|
||||
id := strings.TrimSuffix(filepath.Base(p), ".trajectory.jsonl")
|
||||
switch {
|
||||
case !passed[id]:
|
||||
fmt.Fprintf(os.Stderr, "skip %s: the grader did not pass it\n", id)
|
||||
continue
|
||||
case prompts[id] == "":
|
||||
fmt.Fprintf(os.Stderr, "skip %s: no task prompt under %s\n", id, suite)
|
||||
continue
|
||||
for input, err := range inputs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sample, why := buildSFTSample(id, prompts[id], p)
|
||||
sample, why := buildSFTSampleRecords(input.id, input.prompt, input.records)
|
||||
if sample == nil {
|
||||
fmt.Fprintf(os.Stderr, "skip %s: %s\n", id, why)
|
||||
fmt.Fprintf(os.Stderr, "skip %s: %s\n", input.id, why)
|
||||
continue
|
||||
}
|
||||
if err := enc.Encode(sample); err != nil {
|
||||
@@ -307,23 +312,17 @@ func runSFTMode(dir, suite, reportPath, out string) error {
|
||||
if err := buf.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(f.Name(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "wrote %d of %d runs to %s\n", kept, len(paths), out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sftPrompts(suite string) (map[string]string, error) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(tasks))
|
||||
for _, t := range tasks {
|
||||
out[t.ID] = t.Prompt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sftVerdicts(path string) (map[string]bool, error) {
|
||||
func sftVerdicts(path string) (map[string]map[string]result, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("-report is required: it names the run report whose grader verdicts decide what is kept")
|
||||
}
|
||||
@@ -335,9 +334,27 @@ func sftVerdicts(path string) (map[string]bool, error) {
|
||||
if err := json.Unmarshal(raw, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(results))
|
||||
out := make(map[string]map[string]result, len(results))
|
||||
for _, r := range results {
|
||||
out[r.ID] = r.Passed
|
||||
if r.Skipped {
|
||||
continue
|
||||
}
|
||||
if r.Segments > 1 {
|
||||
return nil, fmt.Errorf("%s: SFT export does not support segmented runs", r.ID)
|
||||
}
|
||||
if r.SFT == nil || r.SFT.RunID == "" || r.SFT.TaskSHA256 == "" || r.SFT.TrajectorySHA256 == "" || r.SFT.Prompt == "" {
|
||||
if !r.Passed {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%s: report lacks SFT provenance; regenerate it with trajectories", r.ID)
|
||||
}
|
||||
if out[r.ID] == nil {
|
||||
out[r.ID] = make(map[string]result)
|
||||
}
|
||||
if _, exists := out[r.ID][r.SFT.TrajectorySHA256]; exists {
|
||||
return nil, fmt.Errorf("%s: duplicate verdict for one trajectory", r.ID)
|
||||
}
|
||||
out[r.ID][r.SFT.TrajectorySHA256] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type sftProvenance struct {
|
||||
RunID string `json:"run_id"`
|
||||
TaskSHA256 string `json:"task_sha256"`
|
||||
Prompt string `json:"prompt"`
|
||||
TrajectorySHA256 string `json:"trajectory_sha256,omitempty"`
|
||||
}
|
||||
|
||||
func newSFTProvenance(t task) (*sftProvenance, error) {
|
||||
digest, err := sftTaskDigest(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sftProvenance{RunID: rand.Text(), TaskSHA256: digest, Prompt: t.Prompt}, nil
|
||||
}
|
||||
|
||||
func sftTaskDigest(t task) (string, error) {
|
||||
h := sha256.New()
|
||||
enc := json.NewEncoder(h)
|
||||
err := filepath.WalkDir(t.dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == t.dir {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(t.dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "solution" && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("task input is not a regular file: %s", path)
|
||||
}
|
||||
digest, err := sftFileDigest(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return enc.Encode(struct {
|
||||
Path string
|
||||
Executable uint32
|
||||
Digest string
|
||||
}{filepath.ToSlash(rel), uint32(info.Mode().Perm() & 0o111), digest})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func sftFileDigest(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// The harness binds its inputs after the child closes the trajectory. Event
|
||||
// bytes stay untouched; the unique run header makes repeated attempts distinct.
|
||||
func bindSFTTrajectory(path string, source *sftProvenance) error {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.CreateTemp(filepath.Dir(path), ".sft-trajectory-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(out.Name())
|
||||
defer out.Close()
|
||||
header := *source
|
||||
header.TrajectorySHA256 = ""
|
||||
if err := json.NewEncoder(out).Encode(struct {
|
||||
Source sftProvenance `json:"benchmark_run"`
|
||||
}{header}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(out.Name(), path); err != nil {
|
||||
return err
|
||||
}
|
||||
source.TrajectorySHA256, err = sftFileDigest(path)
|
||||
return err
|
||||
}
|
||||
|
||||
type sftInput struct {
|
||||
id, prompt string
|
||||
records []sftRecord
|
||||
}
|
||||
|
||||
func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error], error) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byTask := make(map[string]task, len(tasks))
|
||||
for _, t := range tasks {
|
||||
byTask[t.ID] = t
|
||||
}
|
||||
verdicts, err := sftVerdicts(report)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(sftInput, error) bool) {
|
||||
for _, path := range paths {
|
||||
input, err := sftInputFor(path, suite, byTask, verdicts)
|
||||
if err != nil {
|
||||
yield(sftInput{}, err)
|
||||
return
|
||||
}
|
||||
if input != nil && !yield(*input, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
|
||||
id := filepath.Base(path)
|
||||
id = id[:len(id)-len(".trajectory.jsonl")]
|
||||
records, digest, err := readSFTSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, ok := verdicts[id][digest]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: report does not identify this trajectory; regenerate the report and trajectories together", id)
|
||||
}
|
||||
t, ok := tasks[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: task is absent from %s", id, suite)
|
||||
}
|
||||
taskDigest, err := sftTaskDigest(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taskDigest != r.SFT.TaskSHA256 {
|
||||
return nil, fmt.Errorf("%s: task files differ from the graded run", id)
|
||||
}
|
||||
var source *sftProvenance
|
||||
for _, rec := range records {
|
||||
if rec.BenchmarkRun == nil {
|
||||
continue
|
||||
}
|
||||
if source != nil {
|
||||
return nil, fmt.Errorf("%s: multiple benchmark run headers", id)
|
||||
}
|
||||
source = rec.BenchmarkRun
|
||||
}
|
||||
if source == nil || source.RunID != r.SFT.RunID || source.TaskSHA256 != r.SFT.TaskSHA256 || source.Prompt != r.SFT.Prompt {
|
||||
return nil, fmt.Errorf("%s: benchmark run header does not match the report", id)
|
||||
}
|
||||
if !r.Passed || r.Skipped {
|
||||
return nil, nil
|
||||
}
|
||||
return &sftInput{id, source.Prompt, records}, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestSFTRejectsAMixedBatchBeforeReplacingTheOutput(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
writeTraj(t, dir, "fizzbuzz", trajHeader, `{"event":{"kind":"message","text":"failed attempt"}}`, trajUsage)
|
||||
report := filepath.Join(dir, "report.json")
|
||||
if err := os.WriteFile(report, []byte(`[{"ID":"fizzbuzz","Passed":true,"sft":{"trajectory_sha256":"another-run","task_sha256":"another-corpus","prompt":"another prompt"}}]`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "sft.jsonl")
|
||||
if err := os.WriteFile(out, []byte("existing export\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runSFTMode(dir, corpusDir, report, out); err == nil {
|
||||
t.Fatal("a verdict from another run labelled this failed trajectory as passed")
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil || string(got) != "existing export\n" {
|
||||
t.Fatalf("refused export replaced the existing output: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func gradedSFTResult(t *testing.T, dir string, task task, passed bool) result {
|
||||
t.Helper()
|
||||
source, err := newSFTProvenance(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindSFTTrajectory(filepath.Join(dir, task.ID+".trajectory.jsonl"), source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result{task: task, Passed: passed, SFT: source}
|
||||
}
|
||||
|
||||
func sftFixture(t *testing.T) (dir, suite, report string, rows []result) {
|
||||
t.Helper()
|
||||
dir = testenv.TempDir(t)
|
||||
suite = filepath.Join(dir, "suite")
|
||||
taskDir := filepath.Join(suite, "tasks", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range map[string]string{"task.toml": "prompt = 'original prompt'\n", "verify.sh": "exit 0\n", "workdir/seed.txt": "original seed"} {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := tasks[0]
|
||||
actual.Prompt = "planner directive\n\n" + actual.Prompt
|
||||
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
|
||||
rows = []result{gradedSFTResult(t, dir, actual, true)}
|
||||
report = filepath.Join(dir, "report.json")
|
||||
writeSFTReport(t, report, rows)
|
||||
return
|
||||
}
|
||||
|
||||
func writeSFTReport(t *testing.T, path string, rows []result) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTUsesTheRecordedPromptAndAValidatedSnapshot(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
path := filepath.Join(dir, "demo.trajectory.jsonl")
|
||||
inputs, err := sftInputs([]string{path}, suite, report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var captured sftInput
|
||||
count := 0
|
||||
for input, err := range inputs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
captured = input
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d inputs", count)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("a later run replaced this file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sample, why := buildSFTSampleRecords(captured.id, captured.prompt, captured.records)
|
||||
if sample == nil || sample.Messages[1].Content != rows[0].SFT.Prompt {
|
||||
t.Fatalf("lost captured prompt or trajectory: %+v, %s", sample, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTRejectsChangedTaskInputs(t *testing.T) {
|
||||
for _, file := range []string{"task.toml", "verify.sh", "workdir/seed.txt"} {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
dir, suite, report, _ := sftFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(suite, "tasks", "demo", file), []byte("prompt = 'changed'\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl"))
|
||||
if err == nil || !strings.Contains(err.Error(), "task files differ") {
|
||||
t.Fatalf("changed %s accepted: %v", file, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTBindsRepeatedAttemptsToTheirOwnVerdicts(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
|
||||
failed := gradedSFTResult(t, dir, rows[0].task, false)
|
||||
if failed.SFT.RunID == rows[0].SFT.RunID || failed.SFT.TrajectorySHA256 == rows[0].SFT.TrajectorySHA256 {
|
||||
t.Fatal("identical event streams from separate attempts share an identity")
|
||||
}
|
||||
writeSFTReport(t, report, append(rows, failed))
|
||||
out := filepath.Join(dir, "out.jsonl")
|
||||
if err := runSFTMode(dir, suite, report, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(out)
|
||||
if err != nil || len(data) != 0 {
|
||||
t.Fatalf("the latest failed attempt borrowed a passing verdict: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTRefusesLegacyAndDuplicateReports(t *testing.T) {
|
||||
for _, kind := range []string{"legacy", "duplicate", "header"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, suite, report, rows := sftFixture(t)
|
||||
switch kind {
|
||||
case "legacy":
|
||||
rows[0].SFT = nil
|
||||
case "duplicate":
|
||||
rows = append(rows, rows[0])
|
||||
case "header":
|
||||
rows[0].SFT.RunID = "another-run"
|
||||
}
|
||||
writeSFTReport(t, report, rows)
|
||||
if err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl")); err == nil {
|
||||
t.Fatal("unverifiable report accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTaskProducesProvenanceThatSFTCanValidate(t *testing.T) {
|
||||
requireShellStub(t)
|
||||
dir, suite, report, _ := sftFixture(t)
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "agent")
|
||||
script := "#!/usr/bin/env bash\nwhile [ $# -gt 0 ]; do\ncase \"$1\" in\n" +
|
||||
"--trajectory) printf '%s\\n' '" + trajHeader + "' '" + trajUsage + "' '{\"event\":{\"kind\":\"message\",\"text\":\"done\"}}' > \"$2\"; shift 2;;\n" +
|
||||
"--metrics) printf '%s\\n' '{\"steps\":1,\"complete\":true}' > \"$2\"; shift 2;;\n*) shift;;\nesac\ndone\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := runTask(suiteConfig{bin: bin, trajDir: dir, forcePlanner: true}, tasks[0])
|
||||
if !r.Passed || r.SFT == nil || r.SFT.TrajectorySHA256 == "" || r.Trajectory == nil {
|
||||
t.Fatalf("runner lost provenance, grader, or digest: %+v", r)
|
||||
}
|
||||
writeSFTReport(t, report, []result{r})
|
||||
out := filepath.Join(dir, "out.jsonl")
|
||||
if err := runSFTMode(dir, suite, report, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sample sftSample
|
||||
if err := json.Unmarshal(raw, &sample); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sample.Messages[1].Content != r.SFT.Prompt || !r.PlanForced || sample.Messages[1].Content == tasks[0].Prompt {
|
||||
t.Fatal("export did not use the prompt actually passed to the child")
|
||||
}
|
||||
}
|
||||
@@ -122,10 +122,17 @@ func TestRunSFTModeKeepsOnlyGradedPasses(t *testing.T) {
|
||||
writeTraj(t, traj, "palindrome", body...)
|
||||
|
||||
report := filepath.Join(dir, "r.json")
|
||||
raw, _ := json.Marshal([]result{
|
||||
{task: task{ID: "fizzbuzz"}, Passed: true},
|
||||
{task: task{ID: "palindrome"}, Passed: false},
|
||||
})
|
||||
tasks, err := loadTasks(corpusDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []result
|
||||
for _, task := range tasks {
|
||||
if task.ID == "fizzbuzz" || task.ID == "palindrome" {
|
||||
results = append(results, gradedSFTResult(t, traj, task, task.ID == "fizzbuzz"))
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(results)
|
||||
if err := os.WriteFile(report, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+54
-6
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This run replaces the tape. Drop the old completeness markers first, or
|
||||
// a failure here would leave the previous request beside this run's
|
||||
// partial response and read as a complete exchange.
|
||||
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range stale {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if _, err := os.Stat(dir); err != nil {
|
||||
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
|
||||
}
|
||||
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
|
||||
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
|
||||
}
|
||||
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
|
||||
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
|
||||
// tapeWriteFile is a variable so a test can inject a write failure.
|
||||
var tapeWriteFile = os.WriteFile
|
||||
|
||||
// save records one exchange. request.json goes last: its presence is what tells
|
||||
// a later replay the exchange is complete rather than truncated.
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
|
||||
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tape %d response meta: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.json: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.body: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d request.json: %w", index, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load returns the recorded answer to request index, and where the request
|
||||
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
|
||||
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
|
||||
return resp, nil, "", err
|
||||
}
|
||||
recorded, _ := os.ReadFile(t.file(index, "request.json"))
|
||||
recorded, err := os.ReadFile(t.file(index, "request.json"))
|
||||
if err != nil {
|
||||
// Not a divergence: without the recorded request there is nothing to
|
||||
// compare against, and reporting one would blame the harness.
|
||||
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
|
||||
}
|
||||
return resp, body, requestDiff(recorded, request), nil
|
||||
}
|
||||
|
||||
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
|
||||
}
|
||||
return line + "\n\n"
|
||||
}
|
||||
|
||||
// renderTapeRecord reports recordings that did not finish, so a run whose tape
|
||||
// cannot be replayed says so instead of looking recorded.
|
||||
func renderTapeRecord(results []result) string {
|
||||
var incomplete []string
|
||||
for _, r := range results {
|
||||
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
|
||||
continue
|
||||
}
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
|
||||
}
|
||||
if len(incomplete) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var got meterUsage
|
||||
waitFor(t, "the failed write to be reported", func() bool {
|
||||
got = m.snapshot()
|
||||
return got.TapeIncomplete > 0
|
||||
})
|
||||
if !strings.Contains(got.TapeError, "disk full") {
|
||||
t.Fatalf("incomplete recording = %+v, want the write error named", got)
|
||||
}
|
||||
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
|
||||
}
|
||||
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
|
||||
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
|
||||
t.Fatalf("report line = %q", line)
|
||||
}
|
||||
if renderTapeRecord(nil) != "" {
|
||||
t.Fatal("an empty run set produced a tape line")
|
||||
}
|
||||
}
|
||||
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
var markers []string
|
||||
waitFor(t, "the recorded request", func() bool {
|
||||
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(markers) > 0
|
||||
})
|
||||
stop()
|
||||
if err := os.Remove(markers[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
|
||||
defer stop()
|
||||
resp := post(t, base, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
got := m.snapshot()
|
||||
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
|
||||
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
|
||||
}
|
||||
if got.DivergedAt != 0 || got.Divergence != "" {
|
||||
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The run path snapshots the meter through record, which must stop the meter
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
// No waitFor: the drain inside record is what makes this observable.
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("the meter was still serving after record, so its handlers were not drained")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
waitFor(t, "the first recording", func() bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(m) > 0
|
||||
})
|
||||
stop()
|
||||
|
||||
// The second recording fails while writing the body, after the request has
|
||||
// already been replaced in memory.
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "response.body") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls2 atomic.Int32
|
||||
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
|
||||
defer stop2()
|
||||
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
|
||||
var r result
|
||||
runMeter{m: m2, stop: stop2}.record(&r)
|
||||
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("a failed re-record left completeness markers: %v", markers)
|
||||
}
|
||||
// Replay must call the exchange incomplete, not compare a stale request with
|
||||
// a partial response.
|
||||
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
|
||||
defer stop3()
|
||||
resp := post(t, base3, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if got := m3.snapshot(); got.DivergedAt != 0 {
|
||||
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The healthy path must stay quiet: a recording that completed reports no
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
|
||||
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
|
||||
}
|
||||
// renderTapeRecord is the printer behind the report's summary block, so an
|
||||
// empty string here is what "no line is printed" means.
|
||||
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
|
||||
t.Fatalf("a complete recording printed %q, want no line", line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/diskhealth"
|
||||
"reasonix/internal/platform/notify"
|
||||
)
|
||||
|
||||
// diskWatchEvery is how often the volumes are looked at: a build fills a disk
|
||||
// over minutes, and a statfs per folder is cheap.
|
||||
const diskWatchEvery = 5 * time.Minute
|
||||
|
||||
// watchDisk looks at the volumes under dirs on a timer and says when one runs
|
||||
// short of space. The switches are read each time, so turning the notice off or
|
||||
// on in the window takes effect without a restart, and the language is the one
|
||||
// the window is set to now.
|
||||
func watchDisk(ctx context.Context, set *notify.Settings, sender notify.Sender, dirs []string, every time.Duration, read func(string) (diskhealth.Volume, bool)) {
|
||||
monitor := diskhealth.NewMonitor()
|
||||
tick := time.NewTicker(every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
cfg := set.Load()
|
||||
if cfg.Enabled && cfg.DiskLow {
|
||||
for _, n := range monitor.Check(time.Now(), dirs, read) {
|
||||
say := i18n.CatalogFor(config.LoadForEdit(config.UserConfigPath()).DesktopLanguage())
|
||||
notify.SendDiskLow(sender, say, cfg, n)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startDiskWatch begins the watch and returns the sender it speaks through, so
|
||||
// the hub's notices and the disk warning leave by the same door.
|
||||
func startDiskWatch(ctx context.Context, set *notify.Settings, dirs []string) notify.Sender {
|
||||
sender := notify.NewPlatformSender()
|
||||
go watchDisk(ctx, set, sender, dirs, diskWatchEvery, diskhealth.Read)
|
||||
return sender
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/diskhealth"
|
||||
"reasonix/internal/platform/notify"
|
||||
)
|
||||
|
||||
type sentMessages struct {
|
||||
mu sync.Mutex
|
||||
sent []notify.Message
|
||||
}
|
||||
|
||||
func (s *sentMessages) Send(m notify.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sent = append(s.sent, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sentMessages) count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.sent)
|
||||
}
|
||||
|
||||
const gigabyte = int64(1) << 30
|
||||
|
||||
func lowVolume(string) (diskhealth.Volume, bool) {
|
||||
return diskhealth.Volume{Free: 2 * gigabyte, Total: 500 * gigabyte}, true
|
||||
}
|
||||
|
||||
func runWatch(t *testing.T, cfg config.NotificationsConfig) int {
|
||||
t.Helper()
|
||||
sender := &sentMessages{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchDisk(ctx, notify.NewSettings(cfg), sender, []string{"/w", "/w/.state"}, time.Millisecond, lowVolume)
|
||||
close(done)
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
return sender.count()
|
||||
}
|
||||
|
||||
// A volume that stays low is said once however many times it is looked at, and
|
||||
// two folders on it do not make two notices.
|
||||
func TestTheDiskWatchSaysALowVolumeOnce(t *testing.T) {
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: true}); got != 1 {
|
||||
t.Fatalf("sent %d notices, want exactly one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDiskWatchHonoursTheSwitches(t *testing.T) {
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: false}); got != 0 {
|
||||
t.Fatalf("own switch off sent %d, want none", got)
|
||||
}
|
||||
if got := runWatch(t, config.NotificationsConfig{Enabled: false, DiskLow: true}); got != 0 {
|
||||
t.Fatalf("master switch off sent %d, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
"reasonix/internal/platform/appupdate"
|
||||
"reasonix/internal/platform/computer"
|
||||
"reasonix/internal/platform/crashreport"
|
||||
"reasonix/internal/platform/feedback"
|
||||
"reasonix/internal/platform/instanceid"
|
||||
@@ -129,7 +130,9 @@ func main() {
|
||||
// replace it.
|
||||
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
|
||||
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
|
||||
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
|
||||
computerDriver := flag.String("cua-driver", "", "the pinned CUA Driver executable")
|
||||
computerAbortSocket := flag.String("computer-abort-socket", "", "the private Studio CUA interruption endpoint")
|
||||
computerSocket := flag.String("computer-socket", "", "the private embedded CUA socket owned by Studio")
|
||||
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
|
||||
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
|
||||
flag.Parse()
|
||||
@@ -143,10 +146,18 @@ func main() {
|
||||
fmt.Fprintln(os.Stdout, instanceid.Current())
|
||||
return
|
||||
}
|
||||
boot.SetComputerHelper(*computerHelper)
|
||||
var driver *computer.Driver
|
||||
if *computerDriver != "" {
|
||||
if *computerSocket != "" {
|
||||
driver = computer.NewEmbeddedDriver(*computerDriver, *computerSocket, *computerAbortSocket, os.Environ())
|
||||
} else {
|
||||
driver = computer.NewDriver(*computerDriver, os.Environ())
|
||||
}
|
||||
}
|
||||
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
|
||||
releaseFatalLog()
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -171,7 +182,10 @@ func defaultVersion(stated string) string {
|
||||
// the handshake first and then one line per act a handover asks of the shell,
|
||||
// because that pipe is the only channel pointing that way; every log goes to
|
||||
// logs, which is why no line there can be mistaken for one of these.
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity, driver *computer.Driver) int {
|
||||
if driver != nil {
|
||||
defer driver.Close()
|
||||
}
|
||||
phases := newStartupPhases(logs, time.Now)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -194,7 +208,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
|
||||
}
|
||||
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases, driver)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -312,7 +326,7 @@ func resolveKernelLanguage(cfg *config.Config) string {
|
||||
|
||||
// assemble builds the hub this host serves: one pane on the workspace it was
|
||||
// launched in, carrying the capabilities a local window may exercise.
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases, driver *computer.Driver) (*serve.Hub, error) {
|
||||
// The one-time upgrades belong to whichever entry point starts first; a
|
||||
// person who only ever opens the window would otherwise never get them.
|
||||
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
|
||||
@@ -347,20 +361,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
phases.mark(startupSinks)
|
||||
root := launchWorkspace(shell.exe)
|
||||
phases.mark(startupWorkspace)
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
})
|
||||
built, err := boot.BuildRuntime(ctx, runtimeOptions(root, paneSink, logs, phases, driver))
|
||||
phases.mark(startupRuntime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -373,37 +374,51 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
browserHost := serve.NewBrowserHost()
|
||||
boot.SetBrowserHost(browserHost.Dial)
|
||||
hubCfg := hostServeConfig(cfg.Serve)
|
||||
// Shut until the person at the window opens it; the context ending closes
|
||||
// it with the rest of the kernel, unpairing every device.
|
||||
// Shut until the person at the window opens it; the context ending stops it
|
||||
// with the rest of the kernel, keeping the phones this machine already adopted.
|
||||
share := serve.NewDeviceShare(page)
|
||||
share.RestorePort(cfg.SharePort())
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
share.Shutdown()
|
||||
}()
|
||||
owner := &shellOwner{to: handshakeTo}
|
||||
// A toast the user clicks is answered here, in the process that sent it, and
|
||||
// the shell is the one that can bring its window forward.
|
||||
notify.OnActivate(showOnToastClick(owner))
|
||||
notify.OnActivate(showOnToastClick(owner, logs))
|
||||
// A toast that did not go out is invisible to the person it was for, so the
|
||||
// reason belongs in the log the shell keeps.
|
||||
notify.OnSendFailure(func(err error) { fmt.Fprintf(logs, "notify: %v\n", err) })
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
NotifySender: notify.NewPlatformSender(),
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, owner),
|
||||
Share: share,
|
||||
LogsDir: shell.logsDir,
|
||||
NativeSurfaces: true,
|
||||
ComputerDriver: driver,
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
NotifySender: startDiskWatch(ctx, notifySet, []string{root, serve.SessionDirFor(root)}),
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, owner),
|
||||
Share: share,
|
||||
LogsDir: shell.logsDir,
|
||||
})
|
||||
share.Attach(hub.Handler())
|
||||
// A share the person had open reopens where it was: the phone that paired
|
||||
// against that address keeps its cookie. An address that is gone falls back
|
||||
// to one this machine now offers, at the cost of one fresh scan.
|
||||
state := share.RestoreState()
|
||||
if state.Open {
|
||||
if _, err := share.Reopen(state.Address); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: could not reopen phone access:", err)
|
||||
}
|
||||
}
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
srv.SetPaneSink(paneSink)
|
||||
srv.AdoptRuntime(built)
|
||||
@@ -420,6 +435,26 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
// runtimeOptions is what the one pane this host launches with is built from.
|
||||
func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startupPhases, driver *computer.Driver) boot.Options {
|
||||
return boot.Options{
|
||||
ComputerDriver: driver,
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: sink,
|
||||
NativeSurfaces: true,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
}
|
||||
}
|
||||
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
|
||||
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -418,7 +418,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
go func() { _, _ = io.Copy(io.Discard, logs) }()
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}) }()
|
||||
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}, nil) }()
|
||||
|
||||
line, err := bufio.NewReader(stdout).ReadString('\n')
|
||||
if err != nil {
|
||||
@@ -542,7 +542,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,13 @@ const (
|
||||
|
||||
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
|
||||
// only process that can bring its window forward, to do so.
|
||||
func showOnToastClick(o *shellOwner) func() {
|
||||
return func() { _ = o.ask(actShow) }
|
||||
func showOnToastClick(o *shellOwner, log io.Writer) func() {
|
||||
return func() {
|
||||
fmt.Fprintln(log, "notify: toast clicked")
|
||||
if err := o.ask(actShow); err != nil {
|
||||
fmt.Fprintf(log, "notify: toast click could not reach the shell: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellOwner is the application this process was spawned by, reached over the
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -121,8 +123,28 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
|
||||
// in the same shape the other acts take.
|
||||
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &out})()
|
||||
showOnToastClick(&shellOwner{to: &out}, io.Discard)()
|
||||
if got := out.String(); got != "{\"act\":\"show\"}\n" {
|
||||
t.Fatalf("pipe carried %q, want the show act", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The click is the first link of a chain that crosses two processes; when the
|
||||
// window does not come forward, the log says whether the click arrived and
|
||||
// whether the shell was reached.
|
||||
func TestAToastClickIsWrittenToTheLog(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &bytes.Buffer{}}, &log)()
|
||||
if !strings.Contains(log.String(), "toast clicked") {
|
||||
t.Fatalf("log = %q, want the click recorded", log.String())
|
||||
}
|
||||
log.Reset()
|
||||
showOnToastClick(&shellOwner{to: failingWriter{}}, &log)()
|
||||
if !strings.Contains(log.String(), "could not reach the shell") {
|
||||
t.Fatalf("log = %q, want the failed ask recorded", log.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("pipe closed") }
|
||||
@@ -47,7 +47,7 @@ func TestAssembledTrayLanguage(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
build := mappingValue(jobs, "build")
|
||||
entries := mappingValue(mappingValue(mappingValue(build, "strategy"), "matrix"), "include")
|
||||
want := map[string]string{"amd64": "ubuntu-22.04", "arm64": "ubuntu-22.04-arm"}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries.Content {
|
||||
platform := mappingScalar(entry, "platform")
|
||||
if !strings.HasPrefix(platform, "linux/") {
|
||||
continue
|
||||
}
|
||||
arch := strings.TrimPrefix(platform, "linux/")
|
||||
if seen[arch] || mappingScalar(entry, "runner") != want[arch] {
|
||||
t.Fatalf("Linux %s does not have one native runner", arch)
|
||||
}
|
||||
seen[arch] = true
|
||||
artifact := mappingScalar(entry, "artifact")
|
||||
if artifact != "studio-build-linux-"+arch {
|
||||
t.Fatalf("unexpected Linux artifact %q", artifact)
|
||||
}
|
||||
downloaded := false
|
||||
for _, step := range mappingValue(mappingValue(jobs, "publish"), "steps").Content {
|
||||
if mappingScalar(mappingValue(step, "with"), "name") == artifact {
|
||||
downloaded = true
|
||||
}
|
||||
}
|
||||
if !downloaded {
|
||||
t.Errorf("publish never downloads %s", artifact)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("Linux release architectures = %v, want amd64 and arm64", seen)
|
||||
}
|
||||
step := stepNamed(t, build, "Use a native fpm on ARM64 Linux")
|
||||
if mappingScalar(step, "if") != "runner.os == 'Linux' && runner.arch == 'ARM64'" || !strings.Contains(mappingScalar(step, "run"), "USE_SYSTEM_FPM=true") {
|
||||
t.Fatal("Linux ARM64 must retain the upstream native FPM setup")
|
||||
}
|
||||
for _, name := range []string{"Verify both Linux package formats", "Accept the installed Linux package"} {
|
||||
step := stepNamed(t, build, name)
|
||||
if mappingScalar(step, "if") != "runner.os == 'Linux'" || mappingScalar(step, "continue-on-error") == "true" {
|
||||
t.Errorf("%s must gate every Linux build", name)
|
||||
}
|
||||
}
|
||||
collect := mappingScalar(stepNamed(t, mappingValue(jobs, "publish"), "Collect artifacts"), "run")
|
||||
if !strings.Contains(collect, "bash scripts/verify-studio-artifacts.sh dist") {
|
||||
t.Fatal("publication does not require the complete platform artifact set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "scope")
|
||||
run := ""
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if mappingScalar(step, "id") == "pick" {
|
||||
run = mappingScalar(step, "run")
|
||||
}
|
||||
}
|
||||
if run == "" {
|
||||
t.Fatal("no executable CI matrix selector")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
event, file string
|
||||
arm64 bool
|
||||
}{
|
||||
{"push", "docs/test.md", true},
|
||||
{"pull_request", "docs/test.md", false},
|
||||
{"pull_request", "scripts/linux-install-smoke.sh", true},
|
||||
{"pull_request", "scripts/verify-linux-package.sh", true},
|
||||
{"pull_request", "scripts/verify-studio-artifacts.sh", true},
|
||||
{"pull_request", "desktop/cmd/update-helper/main_linux.go", true},
|
||||
{"pull_request", "desktop/electron/test/unit.mjs", true},
|
||||
} {
|
||||
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A shell function owns command lookup on every host, including Git
|
||||
// Bash on Windows; a PATH shim can resolve the runner's real git.
|
||||
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
|
||||
output := filepath.Join(dir, "output")
|
||||
cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+git+run)
|
||||
cmd.Dir = "../.."
|
||||
cmd.Env = append(os.Environ(), "EVENT="+tc.event, "MATRIX_TEST_FILE="+tc.file, "BASE_SHA=fixture", "GITHUB_OUTPUT="+output)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("matrix selector: %v: %s", err, out)
|
||||
}
|
||||
body, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var matrix struct {
|
||||
Include []map[string]string `json:"include"`
|
||||
}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
if value, ok := strings.CutPrefix(line, "matrix="); ok {
|
||||
if err := json.Unmarshal([]byte(value), &matrix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, leg := range matrix.Include {
|
||||
if leg["platform"] == "linux" {
|
||||
seen[leg["arch"]] = leg["os"]
|
||||
}
|
||||
}
|
||||
if seen["amd64"] != "ubuntu-22.04" || (seen["arm64"] == "ubuntu-22.04-arm") != tc.arm64 {
|
||||
t.Fatalf("Linux CI matrix = %v; selector output: %s", seen, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -191,16 +191,11 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 10`) {
|
||||
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
|
||||
if !strings.Contains(run, "bash scripts/verify-studio-artifacts.sh dist") {
|
||||
t.Fatal("publish does not run the complete artifact-set verifier")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
ci := parseWorkflowFile(t, "../../.github/workflows/ci.yml")
|
||||
if !nodeContains(ci, "scripts/studio-artifacts.test.mjs") {
|
||||
t.Fatal("CI does not exercise missing Windows/Linux/macOS artifacts against the publication verifier")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: active
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
reviewed: 2026-10-13
|
||||
---
|
||||
|
||||
# Foundation palette verification
|
||||
|
||||
final result: passed
|
||||
|
||||
Clear Blue and Tea Green implement the two approved colour directions for
|
||||
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
|
||||
The existing default remains the baseline. Layout, typography, copy and brand
|
||||
assets follow the product rather than generated mockup variations.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured from the actual Studio components with synthetic performance-harness
|
||||
data, one conversation turn and an empty composer. Fixture workspace and host
|
||||
names are not real user data. Screenshots use 1:1 density.
|
||||
|
||||
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
|
||||
| --- | --- | --- |
|
||||
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
|
||||
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
|
||||
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
|
||||
|
||||
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
|
||||
| --- | --- | --- |
|
||||
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
|
||||
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
|
||||
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
|
||||
|
||||
## Visual comparison
|
||||
|
||||
The approved light mockups were 1619 × 971 pixels, including a caption outside
|
||||
the application. They were viewed together with the implementation screenshots.
|
||||
Comparison excluded that caption and assessed the palette direction, rather
|
||||
than treating generated layout drift as a product specification.
|
||||
|
||||
- Typography: existing families, size, line height and tracking retained. Plain
|
||||
light palettes now share the default prose weight; user overrides still win.
|
||||
- Spacing: existing grid, controls, corners and panel proportions retained.
|
||||
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
|
||||
directions. Text, syntax and button contrast checks pass in both schemes.
|
||||
- Assets: existing logos and icons retained; no added image or sky layers.
|
||||
- Copy: existing workbench text retained; new palette names and descriptions
|
||||
have Chinese and English labels.
|
||||
|
||||
Full views made the navigation, transcript and composer readable without
|
||||
additional crops. Dark palettes extend the approved hues; the source mockups
|
||||
did not specify a dark layout. Phone captures confirm no horizontal overflow.
|
||||
|
||||
## Findings and iteration history
|
||||
|
||||
1. Default preview inherited the selected pack, and plain packs changed reading
|
||||
surfaces and weight. Shared canonical default variables and plain-surface
|
||||
rules now keep the preview independent and typography consistent.
|
||||
2. Initial comparison found neutral selected navigation and a dark send button
|
||||
overridden by body colour. Shared pack selectors now use accent ink/wash and
|
||||
the accent/foreground pair; disabled controls remain neutral.
|
||||
3. Revised screenshots were compared with both approved directions. No
|
||||
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
|
||||
the expandable collection; the active backdrop remains in the first group.
|
||||
|
||||
## Behavioural evidence
|
||||
|
||||
- Immediate selection, return to default, light/dark and system-following modes
|
||||
verified in the browser; each preview follows its own scheme tokens.
|
||||
- Default comparison: original and revised Studio CSS applied to the same DOM.
|
||||
Fourteen style properties matched for 735 visible elements in each scheme
|
||||
under a light system and 734 in each scheme under a dark system.
|
||||
- Compared colour, background, border, shadow, font, size, weight, line height,
|
||||
tracking, width, height, padding, margin and radius.
|
||||
- Go integration tests cover selection, config persistence, server recreation
|
||||
and reset to the default for both new built-in IDs.
|
||||
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
|
||||
typecheck passed. Browser warning/error logs were empty.
|
||||
- Theme and HTTP server package tests, repository checks and whitespace checks
|
||||
passed. Lint budgets were not increased.
|
||||
|
||||
## Remaining coverage
|
||||
|
||||
Windows native installer acceptance and hosted CI are separate from these
|
||||
local results. The production build retains its existing large-chunk warning.
|
||||
@@ -286,3 +286,57 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
|
||||
}
|
||||
}
|
||||
|
||||
// A release that ships Linux for both architectures keys each package under its
|
||||
// own platform: an arm64 client must never be handed the amd64 .deb, and adding
|
||||
// the second architecture must not move the first one's entries.
|
||||
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
dir := t.TempDir()
|
||||
generate := func() update.Manifest {
|
||||
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m update.Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
write := func(arch string) {
|
||||
for _, ext := range []string{"deb", "rpm"} {
|
||||
name := "ReasonixStudio-linux-" + arch + "." + ext
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
write("amd64")
|
||||
before := generate()
|
||||
write("arm64")
|
||||
after := generate()
|
||||
for key, asset := range before.NativePackages {
|
||||
if after.NativePackages[key] != asset {
|
||||
t.Errorf("existing channel %s changed", key)
|
||||
}
|
||||
}
|
||||
if len(after.NativePackages) != 4 || len(after.Downloads) != 4 || len(after.Platforms) != 0 {
|
||||
t.Fatalf("manifest channels = %+v, want four native packages and human downloads", after)
|
||||
}
|
||||
for ext, key := range map[string]string{"deb": "linux-arm64", "rpm": "linux-arm64-rpm"} {
|
||||
name := "ReasonixStudio-linux-arm64." + ext
|
||||
asset, ok := after.NativePackages[key]
|
||||
if !ok || after.Downloads[name] != asset {
|
||||
t.Fatalf("%s does not resolve to its own download", key)
|
||||
}
|
||||
mirror := update.StudioMirror + "/studio-v2.39.0/" + name
|
||||
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v2.39.0/" + name
|
||||
if asset.URL != mirror || asset.Sig != mirror+".minisig" || asset.Fallback != github || asset.FallbackSig != github+".minisig" {
|
||||
t.Errorf("%s loses its mirror, fallback or signature: %+v", key, asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
// Refs name elements for the model. One is issued per element and never reused,
|
||||
// so a ref read earlier cannot come to mean something else; an element that has
|
||||
// gone answers computer.stale_ref.
|
||||
final class RefTable {
|
||||
private var next = 0
|
||||
private var byHash: [CFHashCode: [(ref: String, element: AXUIElement)]] = [:]
|
||||
private var byRef: [String: (pid: pid_t, element: AXUIElement)] = [:]
|
||||
|
||||
func ref(for element: AXUIElement, pid: pid_t) -> String {
|
||||
let hash = CFHash(element)
|
||||
if let found = byHash[hash]?.first(where: { CFEqual($0.element, element) }) {
|
||||
return found.ref
|
||||
}
|
||||
next += 1
|
||||
let ref = "a\(next)"
|
||||
byHash[hash, default: []].append((ref, element))
|
||||
byRef[ref] = (pid, element)
|
||||
return ref
|
||||
}
|
||||
|
||||
func element(_ ref: String, pid: pid_t) throws -> AXUIElement {
|
||||
guard let entry = byRef[ref] else {
|
||||
throw Failure(code: "computer.unknown_ref", message: "\(ref) was never issued; refs come from a snapshot")
|
||||
}
|
||||
guard entry.pid == pid else {
|
||||
throw Failure(code: "computer.unknown_ref", message: "\(ref) belongs to another application")
|
||||
}
|
||||
var role: AnyObject?
|
||||
if AXUIElementCopyAttributeValue(entry.element, kAXRoleAttribute as CFString, &role) == .invalidUIElement {
|
||||
throw Failure(code: "computer.stale_ref", message: "\(ref) is no longer on screen; take a new snapshot")
|
||||
}
|
||||
return entry.element
|
||||
}
|
||||
}
|
||||
|
||||
// Seen is one element's identity for a single walk: the accessibility API hands
|
||||
// back a new reference for the same element, so equality is the API's, not the
|
||||
// pointer's.
|
||||
struct Seen: Hashable {
|
||||
let element: AXUIElement
|
||||
|
||||
init(_ element: AXUIElement) { self.element = element }
|
||||
|
||||
static func == (a: Seen, b: Seen) -> Bool { CFEqual(a.element, b.element) }
|
||||
|
||||
func hash(into hasher: inout Hasher) { hasher.combine(CFHash(element)) }
|
||||
}
|
||||
|
||||
// The action name for bringing an element into view. The SDK exports no
|
||||
// constant for it, unlike press and show-menu.
|
||||
let scrollToVisibleAction = "AXScrollToVisible"
|
||||
|
||||
enum Accessibility {
|
||||
static let maxNodes = 1500
|
||||
// How far the scroll bar is nudged to measure what the element does with it.
|
||||
static let scrollProbe = 0.1
|
||||
static let maxDepth = 40
|
||||
// Containers that say nothing when unnamed: their children stand in for them.
|
||||
static let transparent: Set<String> = ["AXGroup", "AXScrollArea", "AXSplitGroup", "AXLayoutArea", "AXUnknown"]
|
||||
|
||||
static func app(_ pid: pid_t) -> AXUIElement {
|
||||
let element = AXUIElementCreateApplication(pid)
|
||||
AXUIElementSetMessagingTimeout(element, 1.5)
|
||||
return element
|
||||
}
|
||||
|
||||
static func attr(_ element: AXUIElement, _ name: String) -> AnyObject? {
|
||||
var value: AnyObject?
|
||||
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
|
||||
}
|
||||
|
||||
static func string(_ element: AXUIElement, _ name: String) -> String {
|
||||
switch attr(element, name) {
|
||||
case let s as String: return s
|
||||
case let n as NSNumber: return n.stringValue
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func actions(_ element: AXUIElement) -> [String] {
|
||||
var names: CFArray?
|
||||
return AXUIElementCopyActionNames(element, &names) == .success ? (names as? [String] ?? []) : []
|
||||
}
|
||||
|
||||
static func snapshot(pid: pid_t, refs: RefTable) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
guard let running = NSRunningApplication(processIdentifier: pid) else {
|
||||
throw Failure(code: "computer.no_app", message: "no application with pid \(pid) is running")
|
||||
}
|
||||
let root = app(pid)
|
||||
// Chromium-based applications build their tree only for a client that
|
||||
// asks; the switch is ignored by everything else.
|
||||
AXUIElementSetAttributeValue(root, "AXManualAccessibility" as CFString, kCFBooleanTrue)
|
||||
var windows = (attr(root, kAXWindowsAttribute) as? [AXUIElement]) ?? []
|
||||
if let focused = attr(root, kAXFocusedWindowAttribute), CFGetTypeID(focused) == AXUIElementGetTypeID() {
|
||||
let focusedWindow = focused as! AXUIElement
|
||||
windows.removeAll { CFEqual($0, focusedWindow) }
|
||||
windows.insert(focusedWindow, at: 0)
|
||||
}
|
||||
// An application can hand its own element back in place of every window
|
||||
// it has; what lies under it is the menu bar and itself again. The menus
|
||||
// are still worth reading, and the reader has to be told the rest of the
|
||||
// application is not in here.
|
||||
windows.removeAll { CFEqual($0, root) }
|
||||
var note = ""
|
||||
if windows.isEmpty {
|
||||
note = Screen.locked()
|
||||
? "The screen is locked. While it is, macOS exposes no application's windows through accessibility, so nothing inside this window can be read, clicked by ref or hit by point — only the menus below. Typing, keys and screenshots still reach it; unlocking the screen brings the window back."
|
||||
: "This application exposes no window through accessibility right now, so nothing inside its window can be read, clicked by ref or hit by point — only its menus below. Typing and keys still reach it, a screenshot still shows it, and relaunching it usually brings the window back."
|
||||
windows = [root]
|
||||
}
|
||||
var lines: [String] = []
|
||||
var count = 0
|
||||
// An application's tree is not always one: a window of Calculator lists
|
||||
// the application among its children, and walking that lists the window
|
||||
// again. Every element is walked once per snapshot.
|
||||
var seen = Set<Seen>()
|
||||
func walk(_ element: AXUIElement, _ depth: Int) {
|
||||
if count >= maxNodes || depth > maxDepth { return }
|
||||
if !seen.insert(Seen(element)).inserted { return }
|
||||
count += 1
|
||||
let role = string(element, kAXRoleAttribute)
|
||||
var label = string(element, kAXTitleAttribute)
|
||||
if label.isEmpty { label = string(element, kAXDescriptionAttribute) }
|
||||
let value = string(element, kAXValueAttribute)
|
||||
if label.isEmpty && role == "AXStaticText" { label = value }
|
||||
let children = (attr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? []
|
||||
if transparent.contains(role) && label.isEmpty {
|
||||
for child in children { walk(child, depth) }
|
||||
return
|
||||
}
|
||||
var line = String(repeating: " ", count: depth) + "- " + roleName(role)
|
||||
if !label.isEmpty { line += " \(quote(label))" }
|
||||
line += " [\(refs.ref(for: element, pid: pid))]"
|
||||
if !value.isEmpty && value != label && role != "AXStaticText" { line += " value=\(quote(value))" }
|
||||
if let placeholder = attr(element, kAXPlaceholderValueAttribute) as? String, !placeholder.isEmpty, value.isEmpty {
|
||||
line += " placeholder=\(quote(placeholder))"
|
||||
}
|
||||
if (attr(element, kAXFocusedAttribute) as? Bool) == true { line += " focused" }
|
||||
if (attr(element, kAXEnabledAttribute) as? Bool) == false { line += " disabled" }
|
||||
if (attr(element, kAXSelectedAttribute) as? Bool) == true { line += " selected" }
|
||||
if actions(element).contains(kAXPressAction) { line += " pressable" }
|
||||
lines.append(line)
|
||||
for child in children { walk(child, depth + 1) }
|
||||
}
|
||||
for window in windows { walk(window, 0) }
|
||||
return [
|
||||
"app": running.localizedName ?? "",
|
||||
"bundle": running.bundleIdentifier ?? "",
|
||||
"lines": lines,
|
||||
"truncated": count >= maxNodes,
|
||||
"note": note,
|
||||
]
|
||||
}
|
||||
|
||||
static func roleName(_ role: String) -> String {
|
||||
let bare = role.hasPrefix("AX") ? String(role.dropFirst(2)) : role
|
||||
return bare.isEmpty ? "element" : bare.prefix(1).lowercased() + bare.dropFirst()
|
||||
}
|
||||
|
||||
static func quote(_ s: String) -> String {
|
||||
let clipped = s.count > 160 ? String(s.prefix(160)) + "…" : s
|
||||
let data = try? JSONSerialization.data(withJSONObject: [clipped])
|
||||
let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
|
||||
return String(json.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
static func center(_ element: AXUIElement) -> CGPoint? {
|
||||
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
|
||||
var pos = CGPoint.zero, size = CGSize.zero
|
||||
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &pos), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
|
||||
return CGPoint(x: pos.x + size.width / 2, y: pos.y + size.height / 2)
|
||||
}
|
||||
|
||||
static func perform(_ element: AXUIElement, _ action: String, what: String) throws {
|
||||
switch AXUIElementPerformAction(element, action as CFString) {
|
||||
case .success:
|
||||
return
|
||||
case .invalidUIElement:
|
||||
throw Failure(code: "computer.stale_ref", message: "\(what) is no longer on screen; take a new snapshot")
|
||||
case .actionUnsupported, .attributeUnsupported:
|
||||
throw Failure(code: "computer.no_action", message: "\(what) takes no \(action)")
|
||||
case let error:
|
||||
throw Failure(code: "computer.failed", message: "\(action) on \(what) failed (AXError \(error.rawValue))")
|
||||
}
|
||||
}
|
||||
|
||||
static func press(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
try perform(element, kAXPressAction, what: ref)
|
||||
return [:]
|
||||
}
|
||||
|
||||
// menu asks an element for its context menu, which is what a right click is
|
||||
// for: the menu belongs to the element, so no pointer has to be borrowed to
|
||||
// open it. Read the application again afterwards to see the menu.
|
||||
static func menu(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
guard actions(element).contains(kAXShowMenuAction) else {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) has no context menu")
|
||||
}
|
||||
try perform(element, kAXShowMenuAction, what: ref)
|
||||
return [:]
|
||||
}
|
||||
|
||||
// reveal scrolls an element into view without a pointer. Where that is not
|
||||
// offered, the wheel goes to the application itself, which scrolls whatever
|
||||
// it considers in front — the same thing a wheel over its window would.
|
||||
static func scroll(pid: pid_t, ref: String, amount: Double, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
if !ref.isEmpty {
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
if actions(element).contains(scrollToVisibleAction) {
|
||||
try perform(element, scrollToVisibleAction, what: ref)
|
||||
return ["how": "revealed"]
|
||||
}
|
||||
if try revealBy(element, ref: ref) {
|
||||
return ["how": "revealed"]
|
||||
}
|
||||
}
|
||||
guard amount != 0 else {
|
||||
throw Failure(code: "computer.bad_step", message: "a scroll needs a ref that can be revealed, or lines to turn the wheel by")
|
||||
}
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 1, wheel1: Int32(amount), wheel2: 0, wheel3: 0) else {
|
||||
throw Failure(code: "computer.failed", message: "the scroll could not be made")
|
||||
}
|
||||
event.postToPid(pid)
|
||||
return ["how": "wheel"]
|
||||
}
|
||||
|
||||
// revealBy brings an element into view in applications that offer no way for
|
||||
// it to reveal itself. Their scroll areas advertise page actions that then
|
||||
// refuse to run (measured: AXScrollDownByPage answers -25205 on AppKit),
|
||||
// while the scroll bar's own value does move the view — so the geometry is
|
||||
// solved on that: move the bar a little, see how far the element went with
|
||||
// it, and put the bar where the element lands inside the area.
|
||||
static func revealBy(_ element: AXUIElement, ref: String) throws -> Bool {
|
||||
guard let area = enclosingScroller(element), let bar = verticalBar(area),
|
||||
let want = frame(element), let have = frame(area) else { return false }
|
||||
if have.intersects(want) { return true }
|
||||
guard var at = attr(bar, kAXValueAttribute) as? Double else { return false }
|
||||
let probe = at > 0.5 ? at - scrollProbe : at + scrollProbe
|
||||
guard set(bar, probe), let moved = frame(element) else { return false }
|
||||
let travel = moved.midY - want.midY
|
||||
if abs(travel) < 1 {
|
||||
_ = set(bar, at)
|
||||
return false
|
||||
}
|
||||
// How far one unit of the bar carries the element, and the value that
|
||||
// leaves it in the middle of what the area shows.
|
||||
let perUnit = travel / (probe - at)
|
||||
at = min(max(probe + (have.midY - moved.midY) / perUnit, 0), 1)
|
||||
guard set(bar, at), let landed = frame(element) else { return false }
|
||||
return have.intersects(landed)
|
||||
}
|
||||
|
||||
static func verticalBar(_ area: AXUIElement) -> AXUIElement? {
|
||||
for child in (attr(area, kAXChildrenAttribute) as? [AXUIElement]) ?? [] {
|
||||
guard string(child, kAXRoleAttribute) == "AXScrollBar" else { continue }
|
||||
if (attr(child, kAXOrientationAttribute) as? String) != "AXHorizontalOrientation" {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func set(_ element: AXUIElement, _ value: Double) -> Bool {
|
||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success
|
||||
}
|
||||
|
||||
// enclosingScroller is the nearest ancestor that scrolls, which is what a
|
||||
// scroll bar belongs to.
|
||||
static func enclosingScroller(_ element: AXUIElement) -> AXUIElement? {
|
||||
var at = element
|
||||
for _ in 0..<maxDepth {
|
||||
guard let parent = attr(at, kAXParentAttribute), CFGetTypeID(parent) == AXUIElementGetTypeID() else { return nil }
|
||||
at = parent as! AXUIElement
|
||||
if string(at, kAXRoleAttribute) == "AXScrollArea" { return at }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func frame(_ element: AXUIElement) -> CGRect? {
|
||||
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
|
||||
var origin = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &origin), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
|
||||
return CGRect(origin: origin, size: size)
|
||||
}
|
||||
|
||||
// click is a press at a point: the element there is asked for its action, so
|
||||
// the person's own pointer never moves. An element that has none cannot be
|
||||
// clicked this way, and says so rather than being clicked some other way.
|
||||
static func click(pid: pid_t, x: Double, y: Double, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
var hit: AXUIElement?
|
||||
guard AXUIElementCopyElementAtPosition(app(pid), Float(x), Float(y), &hit) == .success, let element = hit else {
|
||||
throw Failure(code: "computer.no_element", message: "nothing of this application is at (\(Int(x)), \(Int(y)))\(Screen.locked() ? "; the screen is locked, and a locked screen exposes no window to click in" : "")")
|
||||
}
|
||||
// Only an element of the application that was approved takes the click,
|
||||
// whatever the hit test resolves the point to.
|
||||
var owner: pid_t = 0
|
||||
guard AXUIElementGetPid(element, &owner) == .success, owner == pid else {
|
||||
throw Failure(code: "computer.no_element", message: "the element at (\(Int(x)), \(Int(y))) belongs to another application")
|
||||
}
|
||||
cursor.move(to: CGPoint(x: x, y: y))
|
||||
let role = string(element, kAXRoleAttribute)
|
||||
if actions(element).contains(kAXPressAction) {
|
||||
try perform(element, kAXPressAction, what: "the \(roleName(role)) at that point")
|
||||
} else if ["AXTextField", "AXTextArea", "AXComboBox", "AXSearchField"].contains(role) {
|
||||
AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
||||
} else {
|
||||
throw Failure(code: "computer.no_action", message: "the \(roleName(role)) at that point takes no accessibility action\(Screen.locked() ? " — the screen is locked, which is why this application's window is not there to hit" : "; it needs the real pointer")")
|
||||
}
|
||||
return ["role": roleName(role)]
|
||||
}
|
||||
|
||||
static func focus(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
let result = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
||||
if result != .success {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) cannot take focus (AXError \(result.rawValue))")
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func setValue(pid: pid_t, ref: String, text: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let element = try refs.element(ref, pid: pid)
|
||||
if let at = center(element) { cursor.move(to: at) }
|
||||
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFString)
|
||||
if result != .success {
|
||||
throw Failure(code: "computer.no_action", message: "\(ref) has no value that can be set (AXError \(result.rawValue))")
|
||||
}
|
||||
return ["value": string(element, kAXValueAttribute)]
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
enum Apps {
|
||||
// The applications a person can see, with their windows on screen. Window
|
||||
// names need screen recording; without it a window is listed untitled.
|
||||
static func list() -> [JSON] {
|
||||
let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [JSON]) ?? []
|
||||
var byPid: [pid_t: [JSON]] = [:]
|
||||
for w in windows {
|
||||
guard (w[kCGWindowLayer as String] as? Int) == 0,
|
||||
let pid = w[kCGWindowOwnerPID as String] as? pid_t,
|
||||
let id = w[kCGWindowNumber as String] as? Int,
|
||||
let boundsDict = w[kCGWindowBounds as String] as? NSDictionary,
|
||||
let bounds = CGRect(dictionaryRepresentation: boundsDict) else { continue }
|
||||
byPid[pid, default: []].append([
|
||||
"id": id,
|
||||
"title": w[kCGWindowName as String] as? String ?? "",
|
||||
"bounds": ["x": bounds.minX, "y": bounds.minY, "width": bounds.width, "height": bounds.height],
|
||||
])
|
||||
}
|
||||
return NSWorkspace.shared.runningApplications
|
||||
.filter { $0.activationPolicy == .regular }
|
||||
.map { app in
|
||||
[
|
||||
"pid": app.processIdentifier,
|
||||
"bundle": app.bundleIdentifier ?? "",
|
||||
"name": app.localizedName ?? "",
|
||||
"active": app.isActive,
|
||||
"windows": byPid[app.processIdentifier] ?? [],
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import AppKit
|
||||
import ScreenCaptureKit
|
||||
|
||||
enum Capture {
|
||||
// window captures an application's frontmost on-screen window by itself,
|
||||
// whatever covers it, at the display's pixel scale. bounds is where it sits
|
||||
// in global points, which is what a click at a point in the image maps to.
|
||||
//
|
||||
// The answer arrives on whichever thread ScreenCaptureKit finishes on: its
|
||||
// continuations can need the main thread, so waiting for one there is a
|
||||
// deadlock that reads as a capture that never finished.
|
||||
static func window(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
|
||||
guard #available(macOS 14.0, *) else {
|
||||
throw Failure(code: "computer.unsupported", message: "capturing one window needs macOS 14 or later")
|
||||
}
|
||||
try capture(pid: pid, answer: answer)
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
private static func capture(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
|
||||
try Permissions.requireScreenRecording()
|
||||
let once = Once(answer)
|
||||
Task.detached {
|
||||
do {
|
||||
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
||||
guard let target = content.windows
|
||||
.filter({ $0.owningApplication?.processID == pid && $0.windowLayer == 0 && $0.frame.width > 1 })
|
||||
.first else {
|
||||
once.fire(.failure(Failure(code: "computer.no_window", message: "the application has no window on screen")))
|
||||
return
|
||||
}
|
||||
let filter = SCContentFilter(desktopIndependentWindow: target)
|
||||
let config = SCStreamConfiguration()
|
||||
config.width = Int(target.frame.width * CGFloat(filter.pointPixelScale))
|
||||
config.height = Int(target.frame.height * CGFloat(filter.pointPixelScale))
|
||||
config.showsCursor = false
|
||||
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else {
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: "the capture could not be encoded")))
|
||||
return
|
||||
}
|
||||
once.fire(.success([
|
||||
"data": jpeg.base64EncodedString(),
|
||||
"mime": "image/jpeg",
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"window": target.windowID,
|
||||
"bounds": ["x": target.frame.minX, "y": target.frame.minY, "width": target.frame.width, "height": target.frame.height],
|
||||
]))
|
||||
} catch {
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: "\(error.localizedDescription)")))
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 10) {
|
||||
var said = "the capture did not finish within 10s"
|
||||
let others = Processes.siblings()
|
||||
if !others.isEmpty {
|
||||
said += "; another helper is running (pid \(others.map(String.init).joined(separator: ", "))) and while one of them has captured, the other's capture never returns — quit the other Studio"
|
||||
}
|
||||
once.fire(.failure(Failure(code: "computer.capture_failed", message: said)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once carries a request's single answer: the capture and the deadline race for
|
||||
// it, and the request is owed exactly one reply.
|
||||
private final class Once {
|
||||
private let answer: (Result<JSON, Failure>) -> Void
|
||||
private let lock = NSLock()
|
||||
private var fired = false
|
||||
|
||||
init(_ answer: @escaping (Result<JSON, Failure>) -> Void) { self.answer = answer }
|
||||
|
||||
func fire(_ outcome: Result<JSON, Failure>) {
|
||||
lock.lock()
|
||||
let first = !fired
|
||||
fired = true
|
||||
lock.unlock()
|
||||
if first { answer(outcome) }
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
enum Clipboard {
|
||||
// paste puts text where the person is typing without typing it: a long
|
||||
// value arrives at once, and an application that completes or reformats
|
||||
// what it is given sees one insertion instead of a hundred keystrokes.
|
||||
//
|
||||
// The clipboard is the person's. What was on it goes back, unless something
|
||||
// else claimed it while this ran — then theirs is the newer one and stays.
|
||||
static func paste(pid: pid_t, text: String) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
// Measured: an application that is not in front never handles the
|
||||
// keystroke, and bringing it forward is the person's screen being taken
|
||||
// over — which is answered for on its own, not folded into this.
|
||||
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
|
||||
throw Failure(
|
||||
code: "computer.needs_front",
|
||||
message: "pasting needs this application in front; bring it forward with a pointer step, or use type or set_value, which work while it is behind"
|
||||
)
|
||||
}
|
||||
let board = NSPasteboard.general
|
||||
let saved = snapshot(board)
|
||||
let written = board.clearContents()
|
||||
board.setString(text, forType: .string)
|
||||
_ = try Keyboard.press(pid: pid, chord: "cmd+v", times: 1)
|
||||
// The application reads the clipboard when it gets round to handling the
|
||||
// keystroke, and giving it back before then is what hands it the
|
||||
// person's own clipboard to paste instead. So the wait is long, and it
|
||||
// is not the step's to pay: a busy application still has it a second
|
||||
// after this call has answered.
|
||||
Thread.sleep(forTimeInterval: 0.25)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) {
|
||||
Clipboard.restore(board, saved, writtenAt: written)
|
||||
}
|
||||
return ["pasted": text.count]
|
||||
}
|
||||
|
||||
private static func snapshot(_ board: NSPasteboard) -> [[NSPasteboard.PasteboardType: Data]] {
|
||||
(board.pasteboardItems ?? []).map { item in
|
||||
var kept: [NSPasteboard.PasteboardType: Data] = [:]
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
kept[type] = data
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func restore(_ board: NSPasteboard, _ saved: [[NSPasteboard.PasteboardType: Data]], writtenAt: Int) {
|
||||
guard board.changeCount == writtenAt else { return }
|
||||
board.clearContents()
|
||||
guard !saved.isEmpty else { return }
|
||||
board.writeObjects(saved.map { kept in
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in kept {
|
||||
item.setData(data, forType: type)
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// The agent's own pointer: drawn over everything, taking no clicks and no
|
||||
// focus, so a person can see where it acts without it touching theirs.
|
||||
final class VirtualCursor {
|
||||
private let panel: NSPanel
|
||||
private var hideTimer: Timer?
|
||||
private static let size = NSSize(width: 120, height: 44)
|
||||
|
||||
init() {
|
||||
panel = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.ignoresMouseEvents = true
|
||||
panel.level = .screenSaver
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
|
||||
panel.contentView = CursorView(frame: NSRect(origin: .zero, size: Self.size))
|
||||
// Escape while the agent's cursor is on screen is the person saying stop.
|
||||
// Any other time it is their own keystroke and not this process's business.
|
||||
NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self, event.keyCode == 53, self.panel.isVisible else { return }
|
||||
reply(["event": "stop"])
|
||||
self.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// move glides the pointer to a point given in global top-left coordinates,
|
||||
// the ones accessibility and window lists use.
|
||||
func move(to point: CGPoint) {
|
||||
guard let primary = NSScreen.screens.first else { return }
|
||||
let origin = NSPoint(x: point.x - 2, y: primary.frame.height - point.y - Self.size.height + 2)
|
||||
if !panel.isVisible {
|
||||
panel.setFrameOrigin(origin)
|
||||
panel.orderFrontRegardless()
|
||||
} else {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.25
|
||||
panel.animator().setFrameOrigin(origin)
|
||||
}
|
||||
}
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.28))
|
||||
hideTimer?.invalidate()
|
||||
hideTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in self?.hide() }
|
||||
}
|
||||
|
||||
func hide() {
|
||||
hideTimer?.invalidate()
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CursorView: NSView {
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
let top = bounds.height
|
||||
let arrow = NSBezierPath()
|
||||
arrow.move(to: NSPoint(x: 2, y: top - 2))
|
||||
arrow.line(to: NSPoint(x: 2, y: top - 22))
|
||||
arrow.line(to: NSPoint(x: 7.5, y: top - 17))
|
||||
arrow.line(to: NSPoint(x: 11, y: top - 25))
|
||||
arrow.line(to: NSPoint(x: 14, y: top - 23.5))
|
||||
arrow.line(to: NSPoint(x: 10.5, y: top - 16))
|
||||
arrow.line(to: NSPoint(x: 17, y: top - 16))
|
||||
arrow.close()
|
||||
NSColor.systemIndigo.setFill()
|
||||
arrow.fill()
|
||||
NSColor.white.setStroke()
|
||||
arrow.lineWidth = 1.5
|
||||
arrow.stroke()
|
||||
|
||||
let label = "Reasonix" as NSString
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.white]
|
||||
let textSize = label.size(withAttributes: attrs)
|
||||
let pill = NSRect(x: 16, y: top - 40, width: textSize.width + 12, height: textSize.height + 4)
|
||||
NSColor.systemIndigo.setFill()
|
||||
NSBezierPath(roundedRect: pill, xRadius: pill.height / 2, yRadius: pill.height / 2).fill()
|
||||
label.draw(at: NSPoint(x: pill.minX + 6, y: pill.minY + 2), withAttributes: attrs)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import ApplicationServices
|
||||
import Carbon.HIToolbox
|
||||
|
||||
// Keyboard input goes to one process, never to whatever is frontmost, so the
|
||||
// person's own typing elsewhere is not interleaved with it.
|
||||
enum Keyboard {
|
||||
static let named: [String: Int] = [
|
||||
"enter": kVK_Return, "return": kVK_Return, "tab": kVK_Tab, "escape": kVK_Escape, "space": kVK_Space,
|
||||
"backspace": kVK_Delete, "delete": kVK_ForwardDelete,
|
||||
"arrowleft": kVK_LeftArrow, "arrowright": kVK_RightArrow, "arrowup": kVK_UpArrow, "arrowdown": kVK_DownArrow,
|
||||
"home": kVK_Home, "end": kVK_End, "pageup": kVK_PageUp, "pagedown": kVK_PageDown, "insert": kVK_Help,
|
||||
"a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E, "f": kVK_ANSI_F,
|
||||
"g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J, "k": kVK_ANSI_K, "l": kVK_ANSI_L,
|
||||
"m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O, "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R,
|
||||
"s": kVK_ANSI_S, "t": kVK_ANSI_T, "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X,
|
||||
"y": kVK_ANSI_Y, "z": kVK_ANSI_Z,
|
||||
"0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
|
||||
"5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
|
||||
"f1": kVK_F1, "f2": kVK_F2, "f3": kVK_F3, "f4": kVK_F4, "f5": kVK_F5, "f6": kVK_F6,
|
||||
"f7": kVK_F7, "f8": kVK_F8, "f9": kVK_F9, "f10": kVK_F10, "f11": kVK_F11, "f12": kVK_F12,
|
||||
"minus": kVK_ANSI_Minus, "equal": kVK_ANSI_Equal, "comma": kVK_ANSI_Comma, "period": kVK_ANSI_Period,
|
||||
"slash": kVK_ANSI_Slash, "semicolon": kVK_ANSI_Semicolon, "quote": kVK_ANSI_Quote,
|
||||
"bracketleft": kVK_ANSI_LeftBracket, "bracketright": kVK_ANSI_RightBracket,
|
||||
"backslash": kVK_ANSI_Backslash, "backquote": kVK_ANSI_Grave,
|
||||
]
|
||||
static let modifiers: [String: CGEventFlags] = [
|
||||
"shift": .maskShift, "control": .maskControl, "ctrl": .maskControl,
|
||||
"alt": .maskAlternate, "option": .maskAlternate, "meta": .maskCommand, "cmd": .maskCommand,
|
||||
]
|
||||
|
||||
static func type(pid: pid_t, text: String) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
let units = Array(text.utf16)
|
||||
var at = 0
|
||||
while at < units.count {
|
||||
let chunk = Array(units[at..<min(at + 16, units.count)])
|
||||
at += chunk.count
|
||||
for down in [true, false] {
|
||||
guard let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: down) else { continue }
|
||||
chunk.withUnsafeBufferPointer { event.keyboardSetUnicodeString(stringLength: chunk.count, unicodeString: $0.baseAddress) }
|
||||
event.postToPid(pid)
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
// hold keeps a key down, which is what a game or a scrubbing control reads
|
||||
// rather than a press.
|
||||
static func hold(pid: pid_t, chord: String, seconds: Double) throws -> JSON {
|
||||
let (code, flags) = try resolve(chord)
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
if let down = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: true) {
|
||||
down.flags = flags
|
||||
down.postToPid(pid)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: min(max(seconds, 0), 30))
|
||||
if let up = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: false) {
|
||||
up.flags = flags
|
||||
up.postToPid(pid)
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
// resolve reads a chord like "meta+s" into the key and the modifiers held
|
||||
// with it, and says what the vocabulary is when it cannot.
|
||||
static func resolve(_ chord: String) throws -> (Int, CGEventFlags) {
|
||||
try Permissions.requireAccessibility()
|
||||
let parts = chord.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
guard let last = parts.last, let code = named[last] else {
|
||||
throw Failure(code: "computer.bad_step",
|
||||
message: "\(chord) is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the modifiers shift, control, alt and meta. Text goes through the type action")
|
||||
}
|
||||
var flags = CGEventFlags()
|
||||
for part in parts.dropLast() {
|
||||
guard let flag = modifiers[part] else {
|
||||
throw Failure(code: "computer.bad_step", message: "\(part) is not a modifier; use shift, control, alt or meta")
|
||||
}
|
||||
flags.insert(flag)
|
||||
}
|
||||
return (code, flags)
|
||||
}
|
||||
|
||||
static func press(pid: pid_t, chord: String, times: Int) throws -> JSON {
|
||||
let (code, flags) = try resolve(chord)
|
||||
let source = CGEventSource(stateID: .privateState)
|
||||
for _ in 0..<max(times, 1) {
|
||||
for down in [true, false] {
|
||||
guard let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: down) else { continue }
|
||||
event.flags = flags
|
||||
event.postToPid(pid)
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CoreGraphics
|
||||
|
||||
enum Permissions {
|
||||
static func status() -> JSON {
|
||||
["accessibility": AXIsProcessTrusted(), "screen_recording": CGPreflightScreenCaptureAccess(), "screen_locked": Screen.locked()]
|
||||
}
|
||||
|
||||
// Asks the system to show its own prompt, and opens the pane where the
|
||||
// switch lives: a prompt that was dismissed once is not shown again.
|
||||
static func request(_ which: String) throws -> JSON {
|
||||
let pane: String
|
||||
switch which {
|
||||
case "accessibility":
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
pane = "Privacy_Accessibility"
|
||||
case "screen_recording":
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
pane = "Privacy_ScreenCapture"
|
||||
default:
|
||||
throw Failure(code: "computer.bad_request", message: "which must be accessibility or screen_recording")
|
||||
}
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
return status()
|
||||
}
|
||||
|
||||
static func requireAccessibility() throws {
|
||||
if !AXIsProcessTrusted() {
|
||||
throw Failure(code: "computer.permission_missing", message: "accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
static func requireScreenRecording() throws {
|
||||
if !CGPreflightScreenCaptureAccess() {
|
||||
throw Failure(code: "computer.permission_missing", message: "screen_recording")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
// The person's own pointer, moved and clicked by the agent. Every other path in
|
||||
// this helper works through accessibility and leaves the pointer where it is;
|
||||
// this is the one that does not, so the kernel asks for it by name and it hands
|
||||
// the pointer back where it found it.
|
||||
//
|
||||
// A point is acted on only while the window under it belongs to the application
|
||||
// that was approved: on screen the agent has no window of its own to aim at.
|
||||
enum Pointer {
|
||||
private static var parked: CGPoint?
|
||||
|
||||
static func owner(of point: CGPoint) -> pid_t? {
|
||||
let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? []
|
||||
for window in list {
|
||||
guard let layer = window[kCGWindowLayer as String] as? Int, layer == 0,
|
||||
let pid = window[kCGWindowOwnerPID as String] as? pid_t, pid != getpid(),
|
||||
let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
|
||||
let x = bounds["X"], let y = bounds["Y"], let w = bounds["Width"], let h = bounds["Height"] else { continue }
|
||||
if CGRect(x: x, y: y, width: w, height: h).contains(point) { return pid }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// front brings the application forward before the pointer acts on it. A
|
||||
// click into a background window is spent on activating it — macOS gives the
|
||||
// view nothing — so taking the pointer means taking the screen with it.
|
||||
static func front(_ pid: pid_t) {
|
||||
guard let app = NSRunningApplication(processIdentifier: pid), !app.isActive else { return }
|
||||
app.activate(options: [.activateAllWindows])
|
||||
Thread.sleep(forTimeInterval: 0.35)
|
||||
}
|
||||
|
||||
static func check(_ pid: pid_t, _ point: CGPoint) throws {
|
||||
guard let at = owner(of: point) else {
|
||||
throw Failure(code: "computer.no_element", message: "no window is at (\(Int(point.x)), \(Int(point.y)))")
|
||||
}
|
||||
guard at == pid else {
|
||||
throw Failure(code: "computer.no_element", message: "the window at (\(Int(point.x)), \(Int(point.y))) belongs to another application")
|
||||
}
|
||||
}
|
||||
|
||||
// park remembers where the person left their pointer, once per run of steps.
|
||||
static func park() {
|
||||
if parked == nil { parked = CGEvent(source: nil)?.location }
|
||||
}
|
||||
|
||||
// release puts the pointer back where the person left it.
|
||||
static func release() -> JSON {
|
||||
defer { parked = nil }
|
||||
guard let home = parked else { return [:] }
|
||||
post(.mouseMoved, home, .left, 0)
|
||||
return ["returned": true]
|
||||
}
|
||||
|
||||
// position is where the pointer is now, which is what a run that has moved
|
||||
// it has to be able to read back.
|
||||
static func position() -> JSON {
|
||||
let at = CGEvent(source: nil)?.location ?? .zero
|
||||
return ["x": at.x, "y": at.y]
|
||||
}
|
||||
|
||||
static func move(pid: pid_t, to point: CGPoint, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, point)
|
||||
park()
|
||||
cursor.move(to: point)
|
||||
post(.mouseMoved, point, .left, 0)
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func click(pid: pid_t, at point: CGPoint, button: String, clicks: Int, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, point)
|
||||
park()
|
||||
front(pid)
|
||||
cursor.move(to: point)
|
||||
let (down, up, which) = try events(for: button)
|
||||
post(.mouseMoved, point, which, 0)
|
||||
for click in 1...max(clicks, 1) {
|
||||
post(down, point, which, click)
|
||||
post(up, point, which, click)
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
static func drag(pid: pid_t, from: CGPoint, to: CGPoint, cursor: VirtualCursor) throws -> JSON {
|
||||
try Permissions.requireAccessibility()
|
||||
try check(pid, from)
|
||||
try check(pid, to)
|
||||
park()
|
||||
front(pid)
|
||||
cursor.move(to: from)
|
||||
post(.mouseMoved, from, .left, 0)
|
||||
post(.leftMouseDown, from, .left, 1)
|
||||
for step in 1...dragSteps {
|
||||
let at = CGFloat(step) / CGFloat(dragSteps)
|
||||
post(.leftMouseDragged, CGPoint(x: from.x + (to.x - from.x) * at, y: from.y + (to.y - from.y) * at), .left, 1)
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
cursor.move(to: to)
|
||||
post(.leftMouseUp, to, .left, 1)
|
||||
return [:]
|
||||
}
|
||||
|
||||
private static let dragSteps = 12
|
||||
|
||||
private static func events(for button: String) throws -> (CGEventType, CGEventType, CGMouseButton) {
|
||||
switch button {
|
||||
case "", "left":
|
||||
return (.leftMouseDown, .leftMouseUp, .left)
|
||||
case "right":
|
||||
return (.rightMouseDown, .rightMouseUp, .right)
|
||||
case "middle":
|
||||
return (.otherMouseDown, .otherMouseUp, .center)
|
||||
default:
|
||||
throw Failure(code: "computer.bad_step", message: "\(button) is not a mouse button; use left, right or middle")
|
||||
}
|
||||
}
|
||||
|
||||
private static func post(_ type: CGEventType, _ point: CGPoint, _ button: CGMouseButton, _ clicks: Int) {
|
||||
guard let event = CGEvent(mouseEventSource: CGEventSource(stateID: .combinedSessionState), mouseType: type, mouseCursorPosition: point, mouseButton: button) else { return }
|
||||
if clicks > 1 { event.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) }
|
||||
event.post(tap: .cghidEventTap)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
enum Processes {
|
||||
// Other processes running this same executable. Measured on macOS 15: once
|
||||
// one of them has captured a window, a capture in any other never returns,
|
||||
// for any application and with no expiry, until that process exits. It is
|
||||
// what a capture that ran out of time has to say for itself.
|
||||
static func siblings() -> [pid_t] {
|
||||
let me = getpid()
|
||||
guard let mine = path(of: me) else { return [] }
|
||||
var pids = [pid_t](repeating: 0, count: 8192)
|
||||
let bytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
|
||||
guard bytes > 0 else { return [] }
|
||||
return pids[0..<(Int(bytes) / MemoryLayout<pid_t>.size)]
|
||||
.filter { $0 > 0 && $0 != me && path(of: $0) == mine }
|
||||
}
|
||||
|
||||
private static func path(of pid: pid_t) -> String? {
|
||||
// PROC_PIDPATHINFO_MAXSIZE, which libproc.h does not export to Swift.
|
||||
var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
|
||||
guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
// Whether the person's screen is locked. While it is, macOS exposes no
|
||||
// application window through accessibility — every application answers with its
|
||||
// own element in place of its windows — so nothing can be read or clicked by
|
||||
// ref or point, however healthy the application is. Typing, keys and screen
|
||||
// captures still go through.
|
||||
enum Screen {
|
||||
static func locked() -> Bool {
|
||||
guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { return false }
|
||||
return (session["CGSSessionScreenIsLocked"] as? Bool) ?? false
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// The computer-use helper: the one process in Studio that reads other
|
||||
// applications and puts input into them. It decides nothing. The kernel chooses
|
||||
// every target and step after its own permission check, and this carries each
|
||||
// out, answering one JSON line per request line on stdin.
|
||||
|
||||
struct Failure: Error {
|
||||
let code: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
typealias JSON = [String: Any]
|
||||
|
||||
let refs = RefTable()
|
||||
let cursor = VirtualCursor()
|
||||
|
||||
func pointParam(_ params: JSON) throws -> CGPoint {
|
||||
CGPoint(x: try numberParam(params, "x"), y: try numberParam(params, "y"))
|
||||
}
|
||||
|
||||
func handle(_ method: String, _ params: JSON) throws -> JSON {
|
||||
switch method {
|
||||
case "status":
|
||||
return Permissions.status()
|
||||
case "request_permission":
|
||||
return try Permissions.request(params["which"] as? String ?? "")
|
||||
case "apps":
|
||||
return ["apps": Apps.list()]
|
||||
case "snapshot":
|
||||
return try Accessibility.snapshot(pid: try pidParam(params), refs: refs)
|
||||
case "press":
|
||||
return try Accessibility.press(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "click":
|
||||
return try Accessibility.click(pid: try pidParam(params), x: try numberParam(params, "x"), y: try numberParam(params, "y"), cursor: cursor)
|
||||
case "menu":
|
||||
return try Accessibility.menu(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "scroll":
|
||||
return try Accessibility.scroll(pid: try pidParam(params), ref: (params["ref"] as? String) ?? "", amount: (params["amount"] as? Double) ?? 0, refs: refs, cursor: cursor)
|
||||
case "focus":
|
||||
return try Accessibility.focus(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
|
||||
case "set_value":
|
||||
return try Accessibility.setValue(pid: try pidParam(params), ref: try stringParam(params, "ref"), text: try stringParam(params, "text"), refs: refs, cursor: cursor)
|
||||
case "type":
|
||||
return try Keyboard.type(pid: try pidParam(params), text: try stringParam(params, "text"))
|
||||
case "key":
|
||||
return try Keyboard.press(pid: try pidParam(params), chord: try stringParam(params, "key"), times: (params["times"] as? Int) ?? 1)
|
||||
case "paste":
|
||||
return try Clipboard.paste(pid: try pidParam(params), text: try stringParam(params, "text"))
|
||||
case "pointer_move":
|
||||
return try Pointer.move(pid: try pidParam(params), to: try pointParam(params), cursor: cursor)
|
||||
case "pointer_click":
|
||||
return try Pointer.click(pid: try pidParam(params), at: try pointParam(params), button: (params["button"] as? String) ?? "left", clicks: (params["clicks"] as? Int) ?? 1, cursor: cursor)
|
||||
case "pointer_drag":
|
||||
return try Pointer.drag(pid: try pidParam(params), from: try pointParam(params), to: CGPoint(x: try numberParam(params, "to_x"), y: try numberParam(params, "to_y")), cursor: cursor)
|
||||
case "pointer_position":
|
||||
return Pointer.position()
|
||||
case "pointer_release":
|
||||
cursor.hide()
|
||||
return Pointer.release()
|
||||
case "hold_key":
|
||||
return try Keyboard.hold(pid: try pidParam(params), chord: try stringParam(params, "key"), seconds: (params["seconds"] as? Double) ?? 1)
|
||||
case "cursor_hide":
|
||||
cursor.hide()
|
||||
return [:]
|
||||
default:
|
||||
throw Failure(code: "computer.bad_request", message: "unknown method \(method)")
|
||||
}
|
||||
}
|
||||
|
||||
func pidParam(_ params: JSON) throws -> pid_t {
|
||||
guard let n = params["pid"] as? NSNumber, n.int32Value > 0 else {
|
||||
throw Failure(code: "computer.bad_request", message: "pid is required")
|
||||
}
|
||||
return n.int32Value
|
||||
}
|
||||
|
||||
func stringParam(_ params: JSON, _ key: String) throws -> String {
|
||||
guard let s = params[key] as? String else {
|
||||
throw Failure(code: "computer.bad_request", message: "\(key) is required")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func numberParam(_ params: JSON, _ key: String) throws -> Double {
|
||||
guard let n = params[key] as? NSNumber else {
|
||||
throw Failure(code: "computer.bad_request", message: "\(key) is required")
|
||||
}
|
||||
return n.doubleValue
|
||||
}
|
||||
|
||||
// A capture answers from whichever thread it finished on, so the one stream
|
||||
// every answer goes down is written under a lock of its own.
|
||||
let stdoutLock = NSLock()
|
||||
|
||||
func reply(_ body: JSON) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body), var line = String(data: data, encoding: .utf8) else { return }
|
||||
line += "\n"
|
||||
stdoutLock.lock()
|
||||
FileHandle.standardOutput.write(line.data(using: .utf8)!)
|
||||
stdoutLock.unlock()
|
||||
}
|
||||
|
||||
func fail(_ id: Any, _ error: Error) {
|
||||
if let failure = error as? Failure {
|
||||
reply(["id": id, "error": ["code": failure.code, "message": failure.message]])
|
||||
} else {
|
||||
reply(["id": id, "error": ["code": "computer.failed", "message": "\(error)"]])
|
||||
}
|
||||
}
|
||||
|
||||
// Requests are read off the main thread and answered on it: the overlay and the
|
||||
// accessibility calls it animates alongside both belong to the main run loop.
|
||||
Thread.detachNewThread {
|
||||
while let line = readLine() {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let request = (try? JSONSerialization.jsonObject(with: data)) as? JSON,
|
||||
let id = request["id"] else { continue }
|
||||
let method = request["method"] as? String ?? ""
|
||||
let params = request["params"] as? JSON ?? [:]
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
// A capture is the one operation this process waits on rather
|
||||
// than performs, and the main thread is what the rest of them
|
||||
// need: it answers when it is done, out of this order.
|
||||
if method == "screenshot" {
|
||||
try Capture.window(pid: try pidParam(params)) { outcome in
|
||||
switch outcome {
|
||||
case .success(let body): reply(["id": id, "result": body])
|
||||
case .failure(let failure): fail(id, failure)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
reply(["id": id, "result": try handle(method, params)])
|
||||
} catch {
|
||||
fail(id, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Behind every request already queued: stdin closing is the kernel letting
|
||||
// go, and what it asked before that is still owed an answer.
|
||||
DispatchQueue.main.async { exit(0) }
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.run()
|
||||
@@ -1,41 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
// listApps answers the applications a person can see, each with its windows on
|
||||
// screen in physical pixels. A minimized application is listed with none.
|
||||
Json listApps() {
|
||||
struct Walk { std::vector<DWORD> order; std::map<DWORD, bool> listed; } w;
|
||||
EnumWindows([](HWND hwnd, LPARAM l) -> BOOL {
|
||||
auto* w = reinterpret_cast<Walk*>(l);
|
||||
if (!appListed(hwnd)) return TRUE;
|
||||
DWORD pid = contentPid(hwnd);
|
||||
if (pid == 0 || pid == GetCurrentProcessId() || w->listed.count(pid)) return TRUE;
|
||||
w->listed[pid] = true;
|
||||
w->order.push_back(pid);
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(&w));
|
||||
|
||||
HWND fg = GetForegroundWindow();
|
||||
DWORD active = fg ? contentPid(fg) : 0;
|
||||
Json apps = Json::array();
|
||||
for (DWORD pid : w.order) {
|
||||
Json windows = Json::array();
|
||||
for (HWND hwnd : appWindows(pid, false)) {
|
||||
wchar_t title[512];
|
||||
int n = GetWindowTextW(hwnd, title, ARRAYSIZE(title));
|
||||
windows.push(Json::object()
|
||||
.set("id", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
|
||||
.set("title", narrow(std::wstring(title, n > 0 ? n : 0)))
|
||||
.set("bounds", rectJson(windowBounds(hwnd))));
|
||||
}
|
||||
apps.push(Json::object()
|
||||
.set("pid", pid)
|
||||
.set("bundle", identity(pid))
|
||||
.set("exe", exeName(pid))
|
||||
.set("name", displayName(pid))
|
||||
.set("active", pid == active)
|
||||
.set("windows", windows));
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <wincodec.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#ifndef PW_RENDERFULLCONTENT
|
||||
#define PW_RENDERFULLCONTENT 0x00000002
|
||||
#endif
|
||||
|
||||
using Microsoft::WRL::ComPtr;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string base64(const BYTE* data, size_t n) {
|
||||
static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((n + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < n; i += 3) {
|
||||
uint32_t v = data[i] << 16;
|
||||
if (i + 1 < n) v |= data[i + 1] << 8;
|
||||
if (i + 2 < n) v |= data[i + 2];
|
||||
out += table[(v >> 18) & 63];
|
||||
out += table[(v >> 12) & 63];
|
||||
out += i + 1 < n ? table[(v >> 6) & 63] : '=';
|
||||
out += i + 2 < n ? table[v & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void check(HRESULT hr, const char* what) {
|
||||
if (FAILED(hr)) {
|
||||
char buf[96];
|
||||
snprintf(buf, sizeof buf, "%s failed (HRESULT 0x%08lx)", what, static_cast<unsigned long>(hr));
|
||||
throw Failure{"computer.capture_failed", buf};
|
||||
}
|
||||
}
|
||||
|
||||
// jpeg encodes BGRX rows at quality 0.8, the macOS helper's setting.
|
||||
std::vector<BYTE> jpeg(const BYTE* pixels, UINT width, UINT height, UINT stride) {
|
||||
ComPtr<IWICImagingFactory> factory;
|
||||
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)), "creating the image encoder");
|
||||
ComPtr<IWICBitmap> bitmap;
|
||||
check(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppBGR, stride, stride * (height - 1) + width * 4,
|
||||
const_cast<BYTE*>(pixels), &bitmap), "reading the capture");
|
||||
ComPtr<IWICFormatConverter> converted;
|
||||
check(factory->CreateFormatConverter(&converted), "converting the capture");
|
||||
check(converted->Initialize(bitmap.Get(), GUID_WICPixelFormat24bppBGR, WICBitmapDitherTypeNone, nullptr, 0,
|
||||
WICBitmapPaletteTypeCustom), "converting the capture");
|
||||
|
||||
ComPtr<IStream> stream;
|
||||
check(CreateStreamOnHGlobal(nullptr, TRUE, &stream), "allocating the image");
|
||||
ComPtr<IWICBitmapEncoder> encoder;
|
||||
check(factory->CreateEncoder(GUID_ContainerFormatJpeg, nullptr, &encoder), "creating the JPEG encoder");
|
||||
check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "starting the JPEG");
|
||||
ComPtr<IWICBitmapFrameEncode> frame;
|
||||
ComPtr<IPropertyBag2> props;
|
||||
check(encoder->CreateNewFrame(&frame, &props), "starting the JPEG");
|
||||
PROPBAG2 option{};
|
||||
option.pstrName = const_cast<LPOLESTR>(L"ImageQuality");
|
||||
VARIANT quality;
|
||||
VariantInit(&quality);
|
||||
quality.vt = VT_R4;
|
||||
quality.fltVal = 0.8f;
|
||||
props->Write(1, &option, &quality);
|
||||
check(frame->Initialize(props.Get()), "starting the JPEG");
|
||||
check(frame->SetSize(width, height), "sizing the JPEG");
|
||||
WICPixelFormatGUID format = GUID_WICPixelFormat24bppBGR;
|
||||
check(frame->SetPixelFormat(&format), "formatting the JPEG");
|
||||
check(frame->WriteSource(converted.Get(), nullptr), "encoding the JPEG");
|
||||
check(frame->Commit(), "encoding the JPEG");
|
||||
check(encoder->Commit(), "encoding the JPEG");
|
||||
|
||||
STATSTG stat{};
|
||||
check(stream->Stat(&stat, STATFLAG_NONAME), "measuring the JPEG");
|
||||
std::vector<BYTE> out(static_cast<size_t>(stat.cbSize.QuadPart));
|
||||
LARGE_INTEGER zero{};
|
||||
check(stream->Seek(zero, STREAM_SEEK_SET, nullptr), "reading the JPEG");
|
||||
ULONG read = 0;
|
||||
check(stream->Read(out.data(), static_cast<ULONG>(out.size()), &read), "reading the JPEG");
|
||||
out.resize(read);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct Dc {
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC mem = CreateCompatibleDC(screen);
|
||||
HBITMAP bmp = nullptr;
|
||||
~Dc() {
|
||||
if (bmp) DeleteObject(bmp);
|
||||
DeleteDC(mem);
|
||||
ReleaseDC(nullptr, screen);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// capture renders an application's front window by itself, whatever covers
|
||||
// it. bounds is where the visible frame sits on screen, which is what a point
|
||||
// in the image maps back to.
|
||||
Json capture(DWORD pid) {
|
||||
std::vector<HWND> wins = appWindows(pid, false);
|
||||
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window on screen"};
|
||||
HWND hwnd = wins[0];
|
||||
RECT full{};
|
||||
GetWindowRect(hwnd, &full);
|
||||
RECT seen = windowBounds(hwnd);
|
||||
int fw = full.right - full.left, fh = full.bottom - full.top;
|
||||
if (fw <= 1 || fh <= 1) throw Failure{"computer.no_window", "the application has no window on screen"};
|
||||
|
||||
Dc dc;
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
|
||||
bi.bmiHeader.biWidth = fw;
|
||||
bi.bmiHeader.biHeight = -fh;
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
dc.bmp = CreateDIBSection(dc.mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!dc.bmp) throw Failure{"computer.capture_failed", "no memory for a capture of this size"};
|
||||
HGDIOBJ previous = SelectObject(dc.mem, dc.bmp);
|
||||
BOOL printed = PrintWindow(hwnd, dc.mem, PW_RENDERFULLCONTENT);
|
||||
SelectObject(dc.mem, previous);
|
||||
if (!printed) {
|
||||
throw Failure{"computer.capture_failed", screenLocked() ? "the screen is locked, and a locked screen renders no window"
|
||||
: "the window would not render itself for a capture"};
|
||||
}
|
||||
|
||||
int left = std::clamp<int>(seen.left - full.left, 0, fw - 1);
|
||||
int top = std::clamp<int>(seen.top - full.top, 0, fh - 1);
|
||||
int width = std::clamp<int>(seen.right - seen.left, 1, fw - left);
|
||||
int height = std::clamp<int>(seen.bottom - seen.top, 1, fh - top);
|
||||
UINT stride = static_cast<UINT>(fw) * 4;
|
||||
const BYTE* origin = static_cast<const BYTE*>(bits) + static_cast<size_t>(top) * stride + static_cast<size_t>(left) * 4;
|
||||
std::vector<BYTE> encoded = jpeg(origin, width, height, stride);
|
||||
|
||||
RECT placed{full.left + left, full.top + top, full.left + left + width, full.top + top + height};
|
||||
return Json::object()
|
||||
.set("data", base64(encoded.data(), encoded.size()))
|
||||
.set("mime", "image/jpeg")
|
||||
.set("width", width)
|
||||
.set("height", height)
|
||||
.set("window", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
|
||||
.set("bounds", rectJson(placed));
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
// paste puts text where the person is typing without typing it: a long value
|
||||
// arrives at once, and an application that completes or reformats what it is
|
||||
// given sees one insertion instead of a hundred keystrokes.
|
||||
//
|
||||
// The clipboard is the person's. What was on it goes back, unless something
|
||||
// else claimed it while this ran — then theirs is the newer one and stays.
|
||||
|
||||
namespace {
|
||||
|
||||
struct Saved {
|
||||
UINT format;
|
||||
std::vector<BYTE> data;
|
||||
};
|
||||
|
||||
std::mutex restoring;
|
||||
std::thread pending;
|
||||
|
||||
bool open() {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
if (OpenClipboard(nullptr)) return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Formats held as GDI handles rather than memory cannot be copied byte for
|
||||
// byte; each has a memory twin Windows synthesises it back from.
|
||||
bool copyable(UINT format) {
|
||||
return format != CF_BITMAP && format != CF_ENHMETAFILE && format != CF_METAFILEPICT && format != CF_PALETTE &&
|
||||
format != CF_OWNERDISPLAY && format != CF_DSPBITMAP && format != CF_DSPENHMETAFILE && format != CF_DSPMETAFILEPICT;
|
||||
}
|
||||
|
||||
std::vector<Saved> save() {
|
||||
std::vector<Saved> out;
|
||||
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f)) {
|
||||
if (!copyable(f)) continue;
|
||||
HANDLE h = GetClipboardData(f);
|
||||
if (!h) continue;
|
||||
SIZE_T n = GlobalSize(h);
|
||||
const BYTE* p = static_cast<const BYTE*>(GlobalLock(h));
|
||||
if (!p) continue;
|
||||
out.push_back({f, std::vector<BYTE>(p, p + n)});
|
||||
GlobalUnlock(h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void put(UINT format, const void* data, SIZE_T n) {
|
||||
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, n ? n : 1);
|
||||
if (!h) return;
|
||||
void* p = GlobalLock(h);
|
||||
if (n) memcpy(p, data, n);
|
||||
GlobalUnlock(h);
|
||||
if (!SetClipboardData(format, h)) GlobalFree(h);
|
||||
}
|
||||
|
||||
// The agent's text stays out of the person's clipboard history and cloud sync.
|
||||
void keepPrivate() {
|
||||
DWORD no = 0;
|
||||
put(RegisterClipboardFormatW(L"CanIncludeInClipboardHistory"), &no, sizeof no);
|
||||
put(RegisterClipboardFormatW(L"CanUploadToCloudClipboard"), &no, sizeof no);
|
||||
put(RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"), &no, sizeof no);
|
||||
}
|
||||
|
||||
void restore(std::vector<Saved> saved, DWORD written) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
if (GetClipboardSequenceNumber() != written || !open()) return;
|
||||
if (GetClipboardSequenceNumber() != written) {
|
||||
CloseClipboard();
|
||||
return;
|
||||
}
|
||||
EmptyClipboard();
|
||||
for (const Saved& s : saved) put(s.format, s.data.data(), s.data.size());
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void clipboardSettle() {
|
||||
std::lock_guard<std::mutex> hold(restoring);
|
||||
if (pending.joinable()) pending.join();
|
||||
}
|
||||
|
||||
Json paste(DWORD pid, const std::string& text) {
|
||||
front(pid);
|
||||
Landing at = landing(pid, true);
|
||||
refuseHeldText(at);
|
||||
clipboardSettle();
|
||||
if (!open()) throw Failure{"computer.failed", "another application is holding the clipboard"};
|
||||
std::vector<Saved> saved = save();
|
||||
EmptyClipboard();
|
||||
std::wstring w = widen(text);
|
||||
put(CF_UNICODETEXT, w.c_str(), (w.size() + 1) * sizeof(wchar_t));
|
||||
keepPrivate();
|
||||
CloseClipboard();
|
||||
// However this returns, the person's clipboard is owed back.
|
||||
struct GiveBack {
|
||||
std::vector<Saved> saved;
|
||||
DWORD written;
|
||||
~GiveBack() {
|
||||
std::lock_guard<std::mutex> hold(restoring);
|
||||
pending = std::thread(restore, std::move(saved), written);
|
||||
}
|
||||
} giveBack{std::move(saved), GetClipboardSequenceNumber()};
|
||||
if (!isFront(pid)) throw Failure{"computer.needs_front", "the application left the front before the paste; nothing was pasted"};
|
||||
sendChord("control+v");
|
||||
// The application reads the clipboard when it gets round to the keystroke,
|
||||
// and giving it back before then hands it the person's clipboard instead.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
return typedEffect(at, w);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
// The agent's own pointer: drawn over everything, taking no clicks and no
|
||||
// focus, so a person can see where it acts without it touching theirs. It lives
|
||||
// on a thread of its own because the window and the Escape hook both need a
|
||||
// message loop, and the request loop is busy doing the work it shows.
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr UINT moveMessage = WM_APP + 1;
|
||||
constexpr UINT hideMessage = WM_APP + 2;
|
||||
constexpr UINT stopMessage = WM_APP + 3;
|
||||
constexpr UINT_PTR glideTimer = 1, hideTimer = 2;
|
||||
constexpr int glideMs = 250;
|
||||
|
||||
HWND overlay = nullptr;
|
||||
HHOOK escHook = nullptr;
|
||||
std::atomic<bool> showing{false};
|
||||
float scale = 1;
|
||||
int width = 120, height = 44;
|
||||
POINT at{}, from{}, to{};
|
||||
ULONGLONG glideStart = 0;
|
||||
|
||||
void render() {
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
|
||||
bi.bmiHeader.biWidth = width;
|
||||
bi.bmiHeader.biHeight = -height;
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC mem = CreateCompatibleDC(screen);
|
||||
HBITMAP dib = CreateDIBSection(mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
HGDIOBJ old = SelectObject(mem, dib);
|
||||
{
|
||||
Gdiplus::Bitmap canvas(width, height, width * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(bits));
|
||||
Gdiplus::Graphics g(&canvas);
|
||||
g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
|
||||
g.ScaleTransform(scale, scale);
|
||||
Gdiplus::Color indigo(255, 88, 86, 214);
|
||||
Gdiplus::PointF arrow[] = {{2, 2}, {2, 22}, {7.5f, 17}, {11, 25}, {14, 23.5f}, {10.5f, 16}, {17, 16}};
|
||||
Gdiplus::SolidBrush fill(indigo);
|
||||
g.FillPolygon(&fill, arrow, 7);
|
||||
Gdiplus::Pen outline(Gdiplus::Color(255, 255, 255, 255), 1.5f);
|
||||
g.DrawPolygon(&outline, arrow, 7);
|
||||
|
||||
Gdiplus::FontFamily family(L"Segoe UI");
|
||||
Gdiplus::Font font(&family, 11, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
|
||||
Gdiplus::RectF size;
|
||||
g.MeasureString(L"Reasonix", -1, &font, Gdiplus::PointF(0, 0), &size);
|
||||
Gdiplus::RectF pill(16, 23, size.Width + 12, size.Height + 2);
|
||||
Gdiplus::GraphicsPath round;
|
||||
float r = pill.Height / 2;
|
||||
round.AddArc(pill.X, pill.Y, r * 2, pill.Height, 90, 180);
|
||||
round.AddArc(pill.X + pill.Width - r * 2, pill.Y, r * 2, pill.Height, 270, 180);
|
||||
round.CloseFigure();
|
||||
g.FillPath(&fill, &round);
|
||||
Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
|
||||
g.DrawString(L"Reasonix", -1, &font, Gdiplus::PointF(pill.X + 6, pill.Y + 1), &white);
|
||||
}
|
||||
POINT origin{at.x - static_cast<LONG>(2 * scale), at.y - static_cast<LONG>(2 * scale)};
|
||||
SIZE extent{width, height};
|
||||
POINT zero{0, 0};
|
||||
BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
|
||||
UpdateLayeredWindow(overlay, screen, &origin, &extent, mem, &zero, 0, &blend, ULW_ALPHA);
|
||||
SelectObject(mem, old);
|
||||
DeleteObject(dib);
|
||||
DeleteDC(mem);
|
||||
ReleaseDC(nullptr, screen);
|
||||
}
|
||||
|
||||
void hide() {
|
||||
KillTimer(overlay, glideTimer);
|
||||
KillTimer(overlay, hideTimer);
|
||||
ShowWindow(overlay, SW_HIDE);
|
||||
showing = false;
|
||||
if (escHook) {
|
||||
UnhookWindowsHookEx(escHook);
|
||||
escHook = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape while the agent's cursor is on screen is the person saying stop. Any
|
||||
// other time it is their own keystroke, and one this helper injected never is.
|
||||
LRESULT CALLBACK onKey(int code, WPARAM wParam, LPARAM lParam) {
|
||||
if (code == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) {
|
||||
auto* k = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||||
// Answered off the hook: a hook that waits on stdout behind a large
|
||||
// reply overruns its timeout, and Windows removes it for good.
|
||||
if (k->vkCode == VK_ESCAPE && !(k->flags & LLKHF_INJECTED) && showing) PostMessageW(overlay, stopMessage, 0, 0);
|
||||
}
|
||||
return CallNextHookEx(nullptr, code, wParam, lParam);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
switch (msg) {
|
||||
case moveMessage:
|
||||
to = POINT{static_cast<LONG>(static_cast<int>(wParam)), static_cast<LONG>(static_cast<int>(lParam))};
|
||||
if (!showing) {
|
||||
at = from = to;
|
||||
render();
|
||||
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||
showing = true;
|
||||
// Held only while the cursor shows: a system-wide hook costs every
|
||||
// keystroke on the machine a hop through this thread.
|
||||
if (!escHook) escHook = SetWindowsHookExW(WH_KEYBOARD_LL, onKey, GetModuleHandleW(nullptr), 0);
|
||||
} else {
|
||||
from = at;
|
||||
glideStart = GetTickCount64();
|
||||
SetTimer(hwnd, glideTimer, 15, nullptr);
|
||||
}
|
||||
SetTimer(hwnd, hideTimer, 4000, nullptr);
|
||||
return 0;
|
||||
case hideMessage:
|
||||
hide();
|
||||
return 0;
|
||||
case stopMessage:
|
||||
hide();
|
||||
reply(Json::object().set("event", "stop"));
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (wParam == hideTimer) {
|
||||
hide();
|
||||
} else if (wParam == glideTimer) {
|
||||
double t = std::min(1.0, static_cast<double>(GetTickCount64() - glideStart) / glideMs);
|
||||
double ease = t < 0.5 ? 2 * t * t : 1 - (2 - 2 * t) * (2 - 2 * t) / 2;
|
||||
at = POINT{static_cast<LONG>(from.x + (to.x - from.x) * ease), static_cast<LONG>(from.y + (to.y - from.y) * ease)};
|
||||
render();
|
||||
if (t >= 1) KillTimer(hwnd, glideTimer);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
void run(HANDLE ready) {
|
||||
Gdiplus::GdiplusStartupInput input;
|
||||
ULONG_PTR token = 0;
|
||||
Gdiplus::GdiplusStartup(&token, &input, nullptr);
|
||||
scale = GetDpiForSystem() / 96.0f;
|
||||
width = static_cast<int>(120 * scale);
|
||||
height = static_cast<int>(44 * scale);
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = proc;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"ReasonixAgentCursor";
|
||||
RegisterClassW(&wc);
|
||||
overlay = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
|
||||
wc.lpszClassName, L"", WS_POPUP, 0, 0, width, height, nullptr, nullptr, wc.hInstance, nullptr);
|
||||
SetEvent(ready);
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0) DispatchMessageW(&msg);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void cursorStart() {
|
||||
HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
std::thread(run, ready).detach();
|
||||
WaitForSingleObject(ready, 5000);
|
||||
CloseHandle(ready);
|
||||
}
|
||||
|
||||
// cursorMove glides the agent's cursor to a point on screen and waits out the
|
||||
// glide, so what the person sees lands before the action it shows.
|
||||
void cursorMove(POINT point) {
|
||||
if (!overlay) return;
|
||||
PostMessageW(overlay, moveMessage, static_cast<WPARAM>(point.x), static_cast<LPARAM>(point.y));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(glideMs + 30));
|
||||
}
|
||||
|
||||
void cursorHide() {
|
||||
if (overlay) PostMessageW(overlay, hideMessage, 0, 0);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.h"
|
||||
|
||||
// Failure is an operation that did not happen, answered with the same codes the
|
||||
// macOS helper uses so the kernel reads both alike.
|
||||
struct Failure {
|
||||
std::string code;
|
||||
std::string message;
|
||||
Json blockedBy; // the modal that would have taken the input instead
|
||||
};
|
||||
|
||||
std::string narrow(const std::wstring& w);
|
||||
std::wstring widen(const std::string& s);
|
||||
std::string lower(std::string s);
|
||||
std::string num(double v);
|
||||
|
||||
DWORD pidParam(const Json& params);
|
||||
std::string stringParam(const Json& params, const char* key);
|
||||
double numberParam(const Json& params, const char* key);
|
||||
double numberOr(const Json& params, const char* key, double fallback);
|
||||
|
||||
// main.cpp: one line to the kernel, from whichever thread has one to say.
|
||||
void reply(const Json& body);
|
||||
|
||||
// cursor.cpp: the agent's own cursor, and Escape while it shows.
|
||||
void cursorStart();
|
||||
void cursorMove(POINT point);
|
||||
void cursorHide();
|
||||
|
||||
// process.cpp: which application a process or window is, and its windows.
|
||||
std::string identity(DWORD pid);
|
||||
std::string exeName(DWORD pid);
|
||||
std::string displayName(DWORD pid);
|
||||
DWORD contentPid(HWND top);
|
||||
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized);
|
||||
bool appListed(HWND top);
|
||||
void requireOperable(DWORD pid);
|
||||
bool ownsPoint(DWORD pid, POINT at);
|
||||
bool isFront(DWORD pid);
|
||||
HWND heldOwner(HWND window, DWORD pid);
|
||||
void front(DWORD pid);
|
||||
RECT windowBounds(HWND hwnd);
|
||||
Json rectJson(const RECT& r);
|
||||
|
||||
// apps.cpp
|
||||
Json listApps();
|
||||
|
||||
// screen.cpp
|
||||
bool screenLocked();
|
||||
Json status();
|
||||
|
||||
// uia.cpp
|
||||
void uiaStart();
|
||||
Json snapshot(DWORD pid);
|
||||
Json press(DWORD pid, const std::string& ref);
|
||||
Json clickAt(DWORD pid, double x, double y);
|
||||
Json menu(DWORD pid, const std::string& ref);
|
||||
Json scroll(DWORD pid, const std::string& ref, double amount);
|
||||
Json focus(DWORD pid, const std::string& ref);
|
||||
Json setValue(DWORD pid, const std::string& ref, const std::string& text);
|
||||
|
||||
// Landing is where keyboard input to an application goes, read after it is
|
||||
// brought forward and before anything is sent: the modal holding it, if any,
|
||||
// and, when withValue, the focused element with what it holds now.
|
||||
struct Landing {
|
||||
Json modal;
|
||||
bool takesText = false;
|
||||
bool readable = false;
|
||||
std::wstring before;
|
||||
std::function<bool(std::wstring&)> read;
|
||||
};
|
||||
Landing landing(DWORD pid, bool withValue);
|
||||
// refuseHeldText refuses text that would land in a modal on an element that
|
||||
// takes none, where a letter can be a dialog's mnemonic.
|
||||
void refuseHeldText(const Landing& at);
|
||||
// typedEffect grades typing on the focused element read again afterwards.
|
||||
Json typedEffect(const Landing& at, const std::wstring& typed);
|
||||
Json effect(const char* cls, const char* evidence, const Json& modal);
|
||||
|
||||
// keyboard.cpp
|
||||
Json typeText(DWORD pid, const std::string& text);
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times);
|
||||
Json holdKey(DWORD pid, const std::string& chord, double seconds);
|
||||
void sendChord(const std::string& chord);
|
||||
|
||||
// pointer.cpp
|
||||
Json pointerMove(DWORD pid, POINT to);
|
||||
Json pointerClick(DWORD pid, POINT at, const std::string& button, int clicks);
|
||||
Json pointerDrag(DWORD pid, POINT from, POINT to);
|
||||
Json pointerPosition();
|
||||
Json pointerRelease();
|
||||
|
||||
// capture.cpp
|
||||
Json capture(DWORD pid);
|
||||
|
||||
// clipboard.cpp
|
||||
Json paste(DWORD pid, const std::string& text);
|
||||
void clipboardSettle();
|
||||
@@ -1,270 +0,0 @@
|
||||
#include "json.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
Json& Json::push(Json v) {
|
||||
a_.push_back(std::move(v));
|
||||
return *this;
|
||||
}
|
||||
|
||||
Json& Json::set(const std::string& key, Json v) {
|
||||
for (auto& [k, existing] : o_) {
|
||||
if (k == key) {
|
||||
existing = std::move(v);
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
o_.emplace_back(key, std::move(v));
|
||||
return *this;
|
||||
}
|
||||
|
||||
const Json* Json::get(const std::string& key) const {
|
||||
for (const auto& [k, v] : o_) {
|
||||
if (k == key) return &v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string quote(const std::string& s) {
|
||||
std::string out = "\"";
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof buf, "\\u%04x", c);
|
||||
out += buf;
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
void Json::write(std::string& out) const {
|
||||
switch (kind_) {
|
||||
case Kind::Null: out += "null"; break;
|
||||
case Kind::Bool: out += b_ ? "true" : "false"; break;
|
||||
case Kind::Number: {
|
||||
char buf[32];
|
||||
if (!std::isfinite(n_)) {
|
||||
out += "0";
|
||||
} else if (n_ == std::floor(n_) && std::fabs(n_) < 1e15) {
|
||||
std::snprintf(buf, sizeof buf, "%.0f", n_);
|
||||
out += buf;
|
||||
} else {
|
||||
std::snprintf(buf, sizeof buf, "%.17g", n_);
|
||||
out += buf;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Kind::String: out += quote(s_); break;
|
||||
case Kind::Array:
|
||||
out += '[';
|
||||
for (size_t i = 0; i < a_.size(); i++) {
|
||||
if (i) out += ',';
|
||||
a_[i].write(out);
|
||||
}
|
||||
out += ']';
|
||||
break;
|
||||
case Kind::Object:
|
||||
out += '{';
|
||||
for (size_t i = 0; i < o_.size(); i++) {
|
||||
if (i) out += ',';
|
||||
out += quote(o_[i].first);
|
||||
out += ':';
|
||||
o_[i].second.write(out);
|
||||
}
|
||||
out += '}';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Json::dump() const {
|
||||
std::string out;
|
||||
write(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Parser {
|
||||
const std::string& t;
|
||||
size_t i = 0;
|
||||
int depth = 0;
|
||||
|
||||
void space() {
|
||||
while (i < t.size() && (t[i] == ' ' || t[i] == '\t' || t[i] == '\n' || t[i] == '\r')) i++;
|
||||
}
|
||||
|
||||
bool literal(const char* word) {
|
||||
size_t n = std::char_traits<char>::length(word);
|
||||
if (t.compare(i, n, word) != 0) return false;
|
||||
i += n;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void utf8(std::string& out, uint32_t cp) {
|
||||
if (cp < 0x80) {
|
||||
out += static_cast<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
bool hex4(uint32_t& v) {
|
||||
if (i + 4 > t.size()) return false;
|
||||
v = 0;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
char c = t[i++];
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= c - '0';
|
||||
else if (c >= 'a' && c <= 'f') v |= c - 'a' + 10;
|
||||
else if (c >= 'A' && c <= 'F') v |= c - 'A' + 10;
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool str(std::string& out) {
|
||||
if (i >= t.size() || t[i] != '"') return false;
|
||||
i++;
|
||||
while (i < t.size()) {
|
||||
char c = t[i++];
|
||||
if (c == '"') return true;
|
||||
if (static_cast<unsigned char>(c) < 0x20) return false;
|
||||
if (c != '\\') {
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
if (i >= t.size()) return false;
|
||||
switch (t[i++]) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
uint32_t cp;
|
||||
if (!hex4(cp)) return false;
|
||||
if (cp >= 0xD800 && cp < 0xDC00 && t.compare(i, 2, "\\u") == 0) {
|
||||
size_t back = i;
|
||||
i += 2;
|
||||
uint32_t low;
|
||||
if (hex4(low) && low >= 0xDC00 && low < 0xE000) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
|
||||
} else {
|
||||
i = back;
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
} else if (cp >= 0xD800 && cp < 0xE000) {
|
||||
cp = 0xFFFD;
|
||||
}
|
||||
utf8(out, cp);
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool value(Json& out) {
|
||||
if (++depth > 64) return false;
|
||||
space();
|
||||
if (i >= t.size()) return false;
|
||||
char c = t[i];
|
||||
bool ok = true;
|
||||
if (c == '{') {
|
||||
i++;
|
||||
out = Json::object();
|
||||
space();
|
||||
if (i < t.size() && t[i] == '}') {
|
||||
i++;
|
||||
} else {
|
||||
for (;;) {
|
||||
space();
|
||||
std::string key;
|
||||
Json v;
|
||||
if (!str(key)) { ok = false; break; }
|
||||
space();
|
||||
if (i >= t.size() || t[i++] != ':') { ok = false; break; }
|
||||
if (!value(v)) { ok = false; break; }
|
||||
out.set(key, std::move(v));
|
||||
space();
|
||||
if (i < t.size() && t[i] == ',') { i++; continue; }
|
||||
if (i < t.size() && t[i] == '}') { i++; break; }
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (c == '[') {
|
||||
i++;
|
||||
out = Json::array();
|
||||
space();
|
||||
if (i < t.size() && t[i] == ']') {
|
||||
i++;
|
||||
} else {
|
||||
for (;;) {
|
||||
Json v;
|
||||
if (!value(v)) { ok = false; break; }
|
||||
out.push(std::move(v));
|
||||
space();
|
||||
if (i < t.size() && t[i] == ',') { i++; continue; }
|
||||
if (i < t.size() && t[i] == ']') { i++; break; }
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (c == '"') {
|
||||
std::string s;
|
||||
ok = str(s);
|
||||
out = Json(std::move(s));
|
||||
} else if (literal("true")) {
|
||||
out = Json(true);
|
||||
} else if (literal("false")) {
|
||||
out = Json(false);
|
||||
} else if (literal("null")) {
|
||||
out = Json();
|
||||
} else {
|
||||
const char* start = t.c_str() + i;
|
||||
char* end = nullptr;
|
||||
double n = std::strtod(start, &end);
|
||||
ok = end != start;
|
||||
i += end - start;
|
||||
out = Json(n);
|
||||
}
|
||||
depth--;
|
||||
return ok;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Json::parse(const std::string& text, Json& out) {
|
||||
Parser p{text};
|
||||
if (!p.value(out)) return false;
|
||||
p.space();
|
||||
return p.i == text.size();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// Json is the one value type the protocol needs: requests are parsed into it
|
||||
// and every reply is built from it. Objects keep insertion order.
|
||||
class Json {
|
||||
public:
|
||||
enum class Kind { Null, Bool, Number, String, Array, Object };
|
||||
|
||||
Json() = default;
|
||||
Json(bool b) : kind_(Kind::Bool), b_(b) {}
|
||||
Json(int n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(unsigned n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(long n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(unsigned long n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
|
||||
Json(unsigned long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
|
||||
Json(double n) : kind_(Kind::Number), n_(n) {}
|
||||
Json(const char* s) : kind_(Kind::String), s_(s) {}
|
||||
Json(std::string s) : kind_(Kind::String), s_(std::move(s)) {}
|
||||
|
||||
static Json array() { Json j; j.kind_ = Kind::Array; return j; }
|
||||
static Json object() { Json j; j.kind_ = Kind::Object; return j; }
|
||||
|
||||
Kind kind() const { return kind_; }
|
||||
bool isNumber() const { return kind_ == Kind::Number; }
|
||||
bool isString() const { return kind_ == Kind::String; }
|
||||
bool isObject() const { return kind_ == Kind::Object; }
|
||||
double number() const { return n_; }
|
||||
const std::string& string() const { return s_; }
|
||||
const std::vector<Json>& items() const { return a_; }
|
||||
|
||||
Json& push(Json v);
|
||||
Json& set(const std::string& key, Json v);
|
||||
const Json* get(const std::string& key) const;
|
||||
|
||||
std::string dump() const;
|
||||
// parse reads one JSON text; false on anything malformed or trailing.
|
||||
static bool parse(const std::string& text, Json& out);
|
||||
|
||||
private:
|
||||
void write(std::string& out) const;
|
||||
|
||||
Kind kind_ = Kind::Null;
|
||||
bool b_ = false;
|
||||
double n_ = 0;
|
||||
std::string s_;
|
||||
std::vector<Json> a_;
|
||||
std::vector<std::pair<std::string, Json>> o_;
|
||||
};
|
||||
|
||||
// quote renders s as a JSON string literal.
|
||||
std::string quote(const std::string& s);
|
||||
@@ -1,201 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <thread>
|
||||
|
||||
// Keyboard input on Windows goes wherever the foreground is, so every entry
|
||||
// point brings the application forward first and checks it is still there
|
||||
// between chunks: a person switching away must not receive the rest.
|
||||
|
||||
namespace {
|
||||
|
||||
struct Key {
|
||||
WORD vk;
|
||||
bool extended;
|
||||
};
|
||||
|
||||
const std::map<std::string, Key> named = [] {
|
||||
std::map<std::string, Key> keys = {
|
||||
{"enter", {VK_RETURN, false}}, {"return", {VK_RETURN, false}}, {"tab", {VK_TAB, false}},
|
||||
{"escape", {VK_ESCAPE, false}}, {"space", {VK_SPACE, false}}, {"backspace", {VK_BACK, false}},
|
||||
{"delete", {VK_DELETE, true}}, {"insert", {VK_INSERT, true}}, {"arrowleft", {VK_LEFT, true}},
|
||||
{"arrowright", {VK_RIGHT, true}}, {"arrowup", {VK_UP, true}}, {"arrowdown", {VK_DOWN, true}},
|
||||
{"home", {VK_HOME, true}}, {"end", {VK_END, true}}, {"pageup", {VK_PRIOR, true}}, {"pagedown", {VK_NEXT, true}},
|
||||
{"minus", {VK_OEM_MINUS, false}}, {"equal", {VK_OEM_PLUS, false}}, {"comma", {VK_OEM_COMMA, false}},
|
||||
{"period", {VK_OEM_PERIOD, false}}, {"slash", {VK_OEM_2, false}}, {"semicolon", {VK_OEM_1, false}},
|
||||
{"quote", {VK_OEM_7, false}}, {"bracketleft", {VK_OEM_4, false}}, {"bracketright", {VK_OEM_6, false}},
|
||||
{"backslash", {VK_OEM_5, false}}, {"backquote", {VK_OEM_3, false}},
|
||||
};
|
||||
for (char c = 'a'; c <= 'z'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c - 'a' + 'A'), false};
|
||||
for (char c = '0'; c <= '9'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c), false};
|
||||
for (int n = 1; n <= 12; n++) keys["f" + std::to_string(n)] = {static_cast<WORD>(VK_F1 + n - 1), false};
|
||||
return keys;
|
||||
}();
|
||||
|
||||
const std::map<std::string, WORD> modifiers = {
|
||||
{"shift", WORD{VK_SHIFT}}, {"control", WORD{VK_CONTROL}}, {"ctrl", WORD{VK_CONTROL}}, {"alt", WORD{VK_MENU}}, {"option", WORD{VK_MENU}},
|
||||
};
|
||||
|
||||
struct KeyChord {
|
||||
Key key;
|
||||
std::vector<WORD> mods;
|
||||
};
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
|
||||
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||
}
|
||||
|
||||
KeyChord resolve(const std::string& chord) {
|
||||
std::vector<std::string> parts;
|
||||
size_t at = 0;
|
||||
for (;;) {
|
||||
size_t plus = chord.find('+', at);
|
||||
parts.push_back(lower(trim(chord.substr(at, plus == std::string::npos ? std::string::npos : plus - at))));
|
||||
if (plus == std::string::npos) break;
|
||||
at = plus + 1;
|
||||
}
|
||||
auto found = named.find(parts.back());
|
||||
if (found == named.end()) {
|
||||
throw Failure{"computer.bad_step", chord + " is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the "
|
||||
"modifiers shift, control and alt. Text goes through the type action"};
|
||||
}
|
||||
KeyChord c{found->second, {}};
|
||||
for (size_t i = 0; i + 1 < parts.size(); i++) {
|
||||
if (parts[i] == "meta" || parts[i] == "cmd" || parts[i] == "win") {
|
||||
throw Failure{"computer.bad_step", "meta is the Windows key here, which the shell answers rather than the application; "
|
||||
"Windows shortcuts use control, as in control+s"};
|
||||
}
|
||||
auto mod = modifiers.find(parts[i]);
|
||||
if (mod == modifiers.end()) throw Failure{"computer.bad_step", parts[i] + " is not a modifier; use shift, control or alt"};
|
||||
if (std::find(c.mods.begin(), c.mods.end(), mod->second) == c.mods.end()) c.mods.push_back(mod->second);
|
||||
}
|
||||
// These reach the shell's switcher and Start instead of the application.
|
||||
bool alt = std::find(c.mods.begin(), c.mods.end(), VK_MENU) != c.mods.end();
|
||||
bool ctrl = std::find(c.mods.begin(), c.mods.end(), VK_CONTROL) != c.mods.end();
|
||||
if ((alt && (c.key.vk == VK_TAB || c.key.vk == VK_ESCAPE)) || (ctrl && c.key.vk == VK_ESCAPE)) {
|
||||
throw Failure{"computer.bad_step", chord + " switches applications or opens Start; it is answered by Windows, not by this application"};
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
INPUT keyInput(WORD vk, bool extended, bool down) {
|
||||
INPUT in{};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = vk;
|
||||
in.ki.wScan = static_cast<WORD>(MapVirtualKeyW(vk, MAPVK_VK_TO_VSC));
|
||||
in.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) | (extended ? KEYEVENTF_EXTENDEDKEY : 0);
|
||||
return in;
|
||||
}
|
||||
|
||||
INPUT unicodeInput(wchar_t unit, bool down) {
|
||||
INPUT in{};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wScan = unit;
|
||||
in.ki.dwFlags = KEYEVENTF_UNICODE | (down ? 0 : KEYEVENTF_KEYUP);
|
||||
return in;
|
||||
}
|
||||
|
||||
void send(std::vector<INPUT>& inputs) {
|
||||
if (inputs.empty()) return;
|
||||
UINT sent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
|
||||
inputs.clear();
|
||||
if (sent == 0) {
|
||||
throw Failure{"computer.failed", screenLocked() ? "the screen is locked, and Windows delivers no input while it is"
|
||||
: "Windows refused the input"};
|
||||
}
|
||||
}
|
||||
|
||||
void chordDown(std::vector<INPUT>& out, const KeyChord& c) {
|
||||
for (WORD m : c.mods) out.push_back(keyInput(m, false, true));
|
||||
out.push_back(keyInput(c.key.vk, c.key.extended, true));
|
||||
}
|
||||
|
||||
void chordUp(std::vector<INPUT>& out, const KeyChord& c) {
|
||||
out.push_back(keyInput(c.key.vk, c.key.extended, false));
|
||||
for (auto m = c.mods.rbegin(); m != c.mods.rend(); ++m) out.push_back(keyInput(*m, false, false));
|
||||
}
|
||||
|
||||
void stillFront(DWORD pid, size_t done, size_t total, const char* unit) {
|
||||
if (!isFront(pid)) {
|
||||
throw Failure{"computer.needs_front", "the application left the front after " + std::to_string(done) + " of " +
|
||||
std::to_string(total) + " " + unit + "; the rest did not go in"};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Json typeText(DWORD pid, const std::string& text) {
|
||||
std::wstring units = widen(text);
|
||||
front(pid);
|
||||
Landing at = landing(pid, true);
|
||||
refuseHeldText(at);
|
||||
std::vector<INPUT> batch;
|
||||
size_t chunk = 0;
|
||||
for (size_t i = 0; i < units.size(); i++) {
|
||||
if (chunk == 0) stillFront(pid, i, units.size(), "characters");
|
||||
wchar_t u = units[i];
|
||||
if (u == L'\r' && i + 1 < units.size() && units[i + 1] == L'\n') continue;
|
||||
if (u == L'\n' || u == L'\r' || u == L'\t') {
|
||||
WORD vk = u == L'\t' ? VK_TAB : VK_RETURN;
|
||||
batch.push_back(keyInput(vk, false, true));
|
||||
batch.push_back(keyInput(vk, false, false));
|
||||
} else {
|
||||
batch.push_back(unicodeInput(u, true));
|
||||
batch.push_back(unicodeInput(u, false));
|
||||
}
|
||||
if (++chunk == 4) {
|
||||
send(batch);
|
||||
chunk = 0;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
send(batch);
|
||||
return typedEffect(at, units);
|
||||
}
|
||||
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
Json modal = landing(pid, false).modal;
|
||||
std::vector<INPUT> batch;
|
||||
for (int i = 0; i < std::clamp(times, 1, 200); i++) {
|
||||
stillFront(pid, i, times, "presses");
|
||||
chordDown(batch, c);
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
}
|
||||
return effect("unverifiable", nullptr, modal);
|
||||
}
|
||||
|
||||
// holdKey keeps a key down, which is what a game or a scrubbing control reads
|
||||
// rather than a press. The key always comes back up.
|
||||
Json holdKey(DWORD pid, const std::string& chord, double seconds) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
Json modal = landing(pid, false).modal;
|
||||
std::vector<INPUT> batch;
|
||||
chordDown(batch, c);
|
||||
send(batch);
|
||||
// Held keys belong to whatever is in front, so leaving the front ends the
|
||||
// hold at once rather than adding modifiers to the person's own typing.
|
||||
auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(static_cast<long long>(std::clamp(seconds, 0.0, 30.0) * 1000));
|
||||
bool left = false;
|
||||
while (std::chrono::steady_clock::now() < until && !(left = !isFront(pid))) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
if (left) throw Failure{"computer.needs_front", "the application left the front during the hold; the key was released early"};
|
||||
return effect("unverifiable", nullptr, modal);
|
||||
}
|
||||
|
||||
void sendChord(const std::string& chord) {
|
||||
KeyChord c = resolve(chord);
|
||||
std::vector<INPUT> batch;
|
||||
chordDown(batch, c);
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
// The computer-use helper for Windows: the one process in Studio that reads
|
||||
// other applications and puts input into them. It decides nothing. The kernel
|
||||
// chooses every target and step after its own permission check, and this
|
||||
// carries each out, answering one JSON line per request line on stdin.
|
||||
|
||||
#include "helper.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <objbase.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
|
||||
std::string narrow(const std::wstring& w) {
|
||||
if (w.empty()) return {};
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string out(n, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), out.data(), n, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::wstring widen(const std::string& s) {
|
||||
if (s.empty()) return {};
|
||||
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
|
||||
std::wstring out(n, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), out.data(), n);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string num(double v) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof buf, "%.0f", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
DWORD pidParam(const Json& params) {
|
||||
const Json* v = params.get("pid");
|
||||
if (!v || !v->isNumber() || v->number() <= 0 || v->number() > 0xFFFFFFFF) {
|
||||
throw Failure{"computer.bad_request", "pid is required"};
|
||||
}
|
||||
return static_cast<DWORD>(v->number());
|
||||
}
|
||||
|
||||
std::string stringParam(const Json& params, const char* key) {
|
||||
const Json* v = params.get(key);
|
||||
if (!v || !v->isString()) throw Failure{"computer.bad_request", std::string(key) + " is required"};
|
||||
return v->string();
|
||||
}
|
||||
|
||||
double numberParam(const Json& params, const char* key) {
|
||||
const Json* v = params.get(key);
|
||||
if (!v || !v->isNumber()) throw Failure{"computer.bad_request", std::string(key) + " is required"};
|
||||
return v->number();
|
||||
}
|
||||
|
||||
double numberOr(const Json& params, const char* key, double fallback) {
|
||||
const Json* v = params.get(key);
|
||||
return v && v->isNumber() ? v->number() : fallback;
|
||||
}
|
||||
|
||||
static POINT pointParam(const Json& params, const char* x, const char* y) {
|
||||
return POINT{static_cast<LONG>(std::lround(numberParam(params, x))), static_cast<LONG>(std::lround(numberParam(params, y)))};
|
||||
}
|
||||
|
||||
// The kernel's stop event and a request's answer can come from different
|
||||
// threads, so the one stream every line goes down is written under a lock.
|
||||
static std::mutex stdoutLock;
|
||||
|
||||
void reply(const Json& body) {
|
||||
std::string line = body.dump() + "\n";
|
||||
std::lock_guard<std::mutex> hold(stdoutLock);
|
||||
std::fwrite(line.data(), 1, line.size(), stdout);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
static Json handle(const std::string& method, const Json& params) {
|
||||
if (method == "status") return status();
|
||||
if (method == "request_permission") {
|
||||
std::string which = params.get("which") && params.get("which")->isString() ? params.get("which")->string() : "";
|
||||
if (which != "accessibility" && which != "screen_recording") {
|
||||
throw Failure{"computer.bad_request", "which must be accessibility or screen_recording"};
|
||||
}
|
||||
return status();
|
||||
}
|
||||
if (method == "apps") return Json::object().set("apps", listApps());
|
||||
if (method == "pointer_position") return pointerPosition();
|
||||
if (method == "pointer_release") {
|
||||
cursorHide();
|
||||
return pointerRelease();
|
||||
}
|
||||
if (method == "cursor_hide") {
|
||||
cursorHide();
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
static const std::set<std::string> perApp = {"snapshot", "screenshot", "press", "click", "menu", "scroll", "focus", "set_value",
|
||||
"type", "key", "hold_key", "paste", "pointer_move", "pointer_click", "pointer_drag"};
|
||||
if (!perApp.count(method)) throw Failure{"computer.bad_request", "unknown method " + method};
|
||||
DWORD pid = pidParam(params);
|
||||
requireOperable(pid);
|
||||
if (method == "snapshot") return snapshot(pid);
|
||||
if (method == "screenshot") return capture(pid);
|
||||
if (method == "press") return press(pid, stringParam(params, "ref"));
|
||||
if (method == "click") return clickAt(pid, numberParam(params, "x"), numberParam(params, "y"));
|
||||
if (method == "menu") return menu(pid, stringParam(params, "ref"));
|
||||
if (method == "scroll") {
|
||||
std::string ref = params.get("ref") && params.get("ref")->isString() ? params.get("ref")->string() : "";
|
||||
return scroll(pid, ref, numberOr(params, "amount", 0));
|
||||
}
|
||||
if (method == "focus") return focus(pid, stringParam(params, "ref"));
|
||||
if (method == "set_value") return setValue(pid, stringParam(params, "ref"), stringParam(params, "text"));
|
||||
if (method == "type") return typeText(pid, stringParam(params, "text"));
|
||||
if (method == "key") return pressKey(pid, stringParam(params, "key"), static_cast<int>(numberOr(params, "times", 1)));
|
||||
if (method == "hold_key") return holdKey(pid, stringParam(params, "key"), numberOr(params, "seconds", 1));
|
||||
if (method == "paste") return paste(pid, stringParam(params, "text"));
|
||||
if (method == "pointer_move") return pointerMove(pid, pointParam(params, "x", "y"));
|
||||
if (method == "pointer_click") {
|
||||
std::string button = params.get("button") && params.get("button")->isString() ? params.get("button")->string() : "left";
|
||||
return pointerClick(pid, pointParam(params, "x", "y"), button, static_cast<int>(numberOr(params, "clicks", 1)));
|
||||
}
|
||||
return pointerDrag(pid, pointParam(params, "x", "y"), pointParam(params, "to_x", "to_y"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
// Every coordinate this helper reads or writes is a physical pixel: window
|
||||
// bounds, accessibility rectangles, captures and injected input all agree.
|
||||
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
_setmode(_fileno(stdin), _O_BINARY);
|
||||
_setmode(_fileno(stdout), _O_BINARY);
|
||||
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) return 1;
|
||||
uiaStart();
|
||||
cursorStart();
|
||||
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
Json request;
|
||||
if (!Json::parse(line, request) || !request.isObject()) continue;
|
||||
const Json* id = request.get("id");
|
||||
if (!id) continue;
|
||||
const Json* m = request.get("method");
|
||||
std::string method = m && m->isString() ? m->string() : "";
|
||||
const Json* p = request.get("params");
|
||||
Json params = p && p->isObject() ? *p : Json::object();
|
||||
try {
|
||||
reply(Json::object().set("id", *id).set("result", handle(method, params)));
|
||||
} catch (const Failure& f) {
|
||||
Json error = Json::object().set("code", f.code).set("message", f.message);
|
||||
if (f.blockedBy.kind() != Json::Kind::Null) error.set("blocked_by", f.blockedBy);
|
||||
reply(Json::object().set("id", *id).set("error", error));
|
||||
} catch (const std::exception& e) {
|
||||
reply(Json::object().set("id", *id).set("error", Json::object().set("code", "computer.failed").set("message", e.what())));
|
||||
}
|
||||
}
|
||||
// stdin closing is the kernel letting go; a clipboard still borrowed goes
|
||||
// back to the person before this process does.
|
||||
clipboardSettle();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
|
||||
// The person's own pointer, moved and clicked by the agent. Every other path
|
||||
// here works through accessibility and leaves the pointer alone; this one does
|
||||
// not, so the kernel asks for it by name and it goes back where it was found.
|
||||
//
|
||||
// A point is acted on only while the window under it belongs to the approved
|
||||
// application: on screen the agent has no window of its own to aim at.
|
||||
|
||||
namespace {
|
||||
|
||||
std::optional<POINT> parked;
|
||||
constexpr int dragSteps = 12;
|
||||
|
||||
INPUT mouse(POINT at, DWORD flags) {
|
||||
double vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
double vw = GetSystemMetrics(SM_CXVIRTUALSCREEN), vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
INPUT in{};
|
||||
in.type = INPUT_MOUSE;
|
||||
in.mi.dx = static_cast<LONG>(std::lround((at.x - vx) * 65535.0 / std::max(vw - 1, 1.0)));
|
||||
in.mi.dy = static_cast<LONG>(std::lround((at.y - vy) * 65535.0 / std::max(vh - 1, 1.0)));
|
||||
in.mi.dwFlags = flags | MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
|
||||
return in;
|
||||
}
|
||||
|
||||
void post(POINT at, DWORD flags) {
|
||||
INPUT in = mouse(at, flags);
|
||||
if (SendInput(1, &in, sizeof in) == 0) {
|
||||
throw Failure{"computer.failed", screenLocked() ? "the screen is locked, and Windows delivers no input while it is"
|
||||
: "Windows refused the pointer input"};
|
||||
}
|
||||
}
|
||||
|
||||
std::string where(POINT at) { return "(" + std::to_string(at.x) + ", " + std::to_string(at.y) + ")"; }
|
||||
|
||||
void check(DWORD pid, POINT at) {
|
||||
HWND hit = WindowFromPoint(at);
|
||||
if (!hit) throw Failure{"computer.no_element", "no window is at " + where(at)};
|
||||
if (contentPid(GetAncestor(hit, GA_ROOT)) != pid) {
|
||||
throw Failure{"computer.no_element", "the window at " + where(at) + " belongs to another application"};
|
||||
}
|
||||
}
|
||||
|
||||
// aimed checks again at the instant of input: between the first check and the
|
||||
// press a window can come up over the point, and it would take the click.
|
||||
void aimed(DWORD pid, POINT at) {
|
||||
check(pid, at);
|
||||
if (!isFront(pid)) throw Failure{"computer.needs_front", "the application left the front before the pointer acted"};
|
||||
post(at, 0);
|
||||
}
|
||||
|
||||
// park remembers where the person left their pointer, once per run of steps.
|
||||
void park() {
|
||||
if (parked) return;
|
||||
POINT at;
|
||||
if (GetCursorPos(&at)) parked = at;
|
||||
}
|
||||
|
||||
void buttons(const std::string& button, DWORD& down, DWORD& up) {
|
||||
if (button.empty() || button == "left") {
|
||||
down = MOUSEEVENTF_LEFTDOWN, up = MOUSEEVENTF_LEFTUP;
|
||||
} else if (button == "right") {
|
||||
down = MOUSEEVENTF_RIGHTDOWN, up = MOUSEEVENTF_RIGHTUP;
|
||||
} else if (button == "middle") {
|
||||
down = MOUSEEVENTF_MIDDLEDOWN, up = MOUSEEVENTF_MIDDLEUP;
|
||||
} else {
|
||||
throw Failure{"computer.bad_step", button + " is not a mouse button; use left, right or middle"};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Json pointerMove(DWORD pid, POINT to) {
|
||||
check(pid, to);
|
||||
park();
|
||||
cursorMove(to);
|
||||
post(to, 0);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
// A click into a background window is spent activating it, so taking the
|
||||
// pointer means taking the foreground with it. What is on top is checked once
|
||||
// the application is: until then its own window may be covered.
|
||||
Json pointerClick(DWORD pid, POINT at, const std::string& button, int clicks) {
|
||||
DWORD down = 0, up = 0;
|
||||
buttons(button, down, up);
|
||||
front(pid);
|
||||
check(pid, at);
|
||||
park();
|
||||
cursorMove(at);
|
||||
for (int i = 0; i < std::max(clicks, 1); i++) {
|
||||
aimed(pid, at);
|
||||
post(at, down);
|
||||
post(at, up);
|
||||
}
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json pointerDrag(DWORD pid, POINT from, POINT to) {
|
||||
front(pid);
|
||||
check(pid, from);
|
||||
check(pid, to);
|
||||
park();
|
||||
cursorMove(from);
|
||||
aimed(pid, from);
|
||||
post(from, MOUSEEVENTF_LEFTDOWN);
|
||||
// The button comes back up whatever happens on the way: left down, it is
|
||||
// the person's next move that drags.
|
||||
try {
|
||||
for (int step = 1; step <= dragSteps; step++) {
|
||||
double t = static_cast<double>(step) / dragSteps;
|
||||
post(POINT{static_cast<LONG>(std::lround(from.x + (to.x - from.x) * t)), static_cast<LONG>(std::lround(from.y + (to.y - from.y) * t))}, 0);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
aimed(pid, to);
|
||||
} catch (...) {
|
||||
INPUT release = mouse(to, MOUSEEVENTF_LEFTUP);
|
||||
SendInput(1, &release, sizeof release);
|
||||
throw;
|
||||
}
|
||||
post(to, MOUSEEVENTF_LEFTUP);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json pointerPosition() {
|
||||
POINT at{};
|
||||
GetCursorPos(&at);
|
||||
return Json::object().set("x", static_cast<long>(at.x)).set("y", static_cast<long>(at.y));
|
||||
}
|
||||
|
||||
// pointerRelease puts the pointer back where the person left it.
|
||||
Json pointerRelease() {
|
||||
if (!parked) return Json::object();
|
||||
POINT home = *parked;
|
||||
parked.reset();
|
||||
post(home, 0);
|
||||
return Json::object().set("returned", true);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <appmodel.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
|
||||
struct Handle {
|
||||
HANDLE h;
|
||||
explicit Handle(HANDLE h) : h(h) {}
|
||||
~Handle() { if (h) CloseHandle(h); }
|
||||
Handle(const Handle&) = delete;
|
||||
Handle& operator=(const Handle&) = delete;
|
||||
};
|
||||
|
||||
std::wstring imagePath(DWORD pid) {
|
||||
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
|
||||
if (!p.h) return {};
|
||||
wchar_t buf[MAX_PATH * 4];
|
||||
DWORD n = ARRAYSIZE(buf);
|
||||
return QueryFullProcessImageNameW(p.h, 0, buf, &n) ? std::wstring(buf, n) : std::wstring();
|
||||
}
|
||||
|
||||
std::wstring fileName(const std::wstring& path) {
|
||||
size_t slash = path.find_last_of(L"\\/");
|
||||
return slash == std::wstring::npos ? path : path.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::wstring packageFamily(DWORD pid) {
|
||||
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
|
||||
if (!p.h) return {};
|
||||
UINT32 len = 0;
|
||||
if (GetPackageFamilyName(p.h, &len, nullptr) != ERROR_INSUFFICIENT_BUFFER || len == 0) return {};
|
||||
std::wstring name(len, L'\0');
|
||||
if (GetPackageFamilyName(p.h, &len, name.data()) != ERROR_SUCCESS) return {};
|
||||
name.resize(wcslen(name.c_str()));
|
||||
return name;
|
||||
}
|
||||
|
||||
bool cloaked(HWND hwnd) {
|
||||
DWORD c = 0;
|
||||
return SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &c, sizeof c)) && c != 0;
|
||||
}
|
||||
|
||||
std::wstring className(HWND hwnd) {
|
||||
wchar_t buf[256];
|
||||
int n = GetClassNameW(hwnd, buf, ARRAYSIZE(buf));
|
||||
return std::wstring(buf, n > 0 ? n : 0);
|
||||
}
|
||||
|
||||
// The shell's own surfaces are windows of explorer.exe like any other, and
|
||||
// none of them is an application a person would name.
|
||||
bool shellSurface(HWND hwnd) {
|
||||
std::wstring c = className(hwnd);
|
||||
return c == L"Progman" || c == L"WorkerW" || c == L"Shell_TrayWnd" || c == L"Shell_SecondaryTrayWnd" || c == L"#32768";
|
||||
}
|
||||
|
||||
// A console window reports whichever program owns the console — cmd, python,
|
||||
// wsl, ssh — rather than its host, so no name can refuse it. It is recognised
|
||||
// by what it is and belongs to no application this helper will operate.
|
||||
bool console(HWND hwnd) {
|
||||
std::wstring c = className(hwnd);
|
||||
return c == L"ConsoleWindowClass" || c == L"PseudoConsoleWindow" || c == L"CASCADIA_HOSTING_WINDOW_CLASS";
|
||||
}
|
||||
|
||||
bool intact(HWND hwnd) {
|
||||
if (!IsWindowVisible(hwnd) || cloaked(hwnd) || shellSurface(hwnd) || console(hwnd)) return false;
|
||||
return (GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) == 0;
|
||||
}
|
||||
|
||||
enum class Level { Known, Denied, Unknown };
|
||||
|
||||
DWORD integrity(HANDLE process, Level& known) {
|
||||
known = Level::Unknown;
|
||||
HANDLE token = nullptr;
|
||||
if (!OpenProcessToken(process, TOKEN_QUERY, &token)) {
|
||||
if (GetLastError() == ERROR_ACCESS_DENIED) known = Level::Denied;
|
||||
return 0;
|
||||
}
|
||||
Handle t(token);
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &size);
|
||||
std::vector<BYTE> buf(size);
|
||||
if (size == 0 || !GetTokenInformation(token, TokenIntegrityLevel, buf.data(), size, &size)) return 0;
|
||||
auto* label = reinterpret_cast<TOKEN_MANDATORY_LABEL*>(buf.data());
|
||||
known = Level::Known;
|
||||
return *GetSidSubAuthority(label->Label.Sid, *GetSidSubAuthorityCount(label->Label.Sid) - 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string identity(DWORD pid) {
|
||||
std::wstring family = packageFamily(pid);
|
||||
if (!family.empty()) return narrow(family);
|
||||
return exeName(pid);
|
||||
}
|
||||
|
||||
std::string exeName(DWORD pid) { return lower(narrow(fileName(imagePath(pid)))); }
|
||||
|
||||
std::string displayName(DWORD pid) {
|
||||
std::wstring path = imagePath(pid);
|
||||
DWORD ignored = 0;
|
||||
DWORD size = GetFileVersionInfoSizeW(path.c_str(), &ignored);
|
||||
if (size > 0) {
|
||||
std::vector<BYTE> info(size);
|
||||
struct Translation { WORD language, codepage; }* tr = nullptr;
|
||||
UINT len = 0;
|
||||
if (GetFileVersionInfoW(path.c_str(), 0, size, info.data()) &&
|
||||
VerQueryValueW(info.data(), L"\\VarFileInfo\\Translation", reinterpret_cast<void**>(&tr), &len) && len >= sizeof *tr) {
|
||||
wchar_t key[64];
|
||||
swprintf_s(key, L"\\StringFileInfo\\%04x%04x\\FileDescription", tr->language, tr->codepage);
|
||||
wchar_t* desc = nullptr;
|
||||
if (VerQueryValueW(info.data(), key, reinterpret_cast<void**>(&desc), &len) && len > 1 && desc[0]) {
|
||||
return narrow(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::wstring name = fileName(path);
|
||||
size_t dot = name.find_last_of(L'.');
|
||||
return narrow(dot == std::wstring::npos ? name : name.substr(0, dot));
|
||||
}
|
||||
|
||||
// contentPid is the process a top-level window shows. A Store application's
|
||||
// frame belongs to ApplicationFrameHost; what is inside it is the application's
|
||||
// own CoreWindow, and a frame with none inside has nothing of it to show.
|
||||
DWORD contentPid(HWND top) {
|
||||
DWORD pid = 0;
|
||||
if (console(top)) return 0;
|
||||
GetWindowThreadProcessId(top, &pid);
|
||||
if (className(top) != L"ApplicationFrameWindow") return pid;
|
||||
struct Find { DWORD host; DWORD found; } f{pid, 0};
|
||||
EnumChildWindows(top, [](HWND child, LPARAM l) -> BOOL {
|
||||
auto* f = reinterpret_cast<Find*>(l);
|
||||
if (className(child) != L"Windows.UI.Core.CoreWindow") return TRUE;
|
||||
DWORD p = 0;
|
||||
GetWindowThreadProcessId(child, &p);
|
||||
if (p == f->host) return TRUE;
|
||||
f->found = p;
|
||||
return FALSE;
|
||||
}, reinterpret_cast<LPARAM>(&f));
|
||||
return f.found;
|
||||
}
|
||||
|
||||
// appListed is whether a window makes its process an application a person
|
||||
// can see: the windows the task switcher offers, minimized ones included.
|
||||
bool appListed(HWND top) {
|
||||
if (!intact(top)) return false;
|
||||
LONG_PTR ex = GetWindowLongPtrW(top, GWL_EXSTYLE);
|
||||
if (ex & WS_EX_APPWINDOW) return true;
|
||||
return GetWindow(top, GW_OWNER) == nullptr && (ex & WS_EX_NOACTIVATE) == 0;
|
||||
}
|
||||
|
||||
// appWindows lists a process's top-level windows front to back, dialogs it
|
||||
// owns included, with the one in front of the person first.
|
||||
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
|
||||
struct Walk { DWORD pid; bool minimized; std::vector<HWND> out; } w{pid, includeMinimized, {}};
|
||||
EnumWindows([](HWND hwnd, LPARAM l) -> BOOL {
|
||||
auto* w = reinterpret_cast<Walk*>(l);
|
||||
if (!intact(hwnd) || contentPid(hwnd) != w->pid) return TRUE;
|
||||
if (IsIconic(hwnd)) {
|
||||
if (w->minimized) w->out.push_back(hwnd);
|
||||
return TRUE;
|
||||
}
|
||||
RECT r = windowBounds(hwnd);
|
||||
if (r.right - r.left > 1 && r.bottom - r.top > 1) w->out.push_back(hwnd);
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(&w));
|
||||
HWND fg = GetForegroundWindow();
|
||||
for (size_t i = 1; i < w.out.size(); i++) {
|
||||
if (w.out[i] == fg) {
|
||||
w.out.erase(w.out.begin() + i);
|
||||
w.out.insert(w.out.begin(), fg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return w.out;
|
||||
}
|
||||
|
||||
// requireOperable refuses a process that is gone, and one Windows keeps this
|
||||
// helper from reaching: input and accessibility stop at a process running with
|
||||
// more privilege, and failing there without a cause reads as a broken app.
|
||||
void requireOperable(DWORD pid) {
|
||||
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
|
||||
bool unreachable = !p.h && GetLastError() == ERROR_ACCESS_DENIED;
|
||||
DWORD code = 0;
|
||||
if (!unreachable && (!p.h || !GetExitCodeProcess(p.h, &code) || code != STILL_ACTIVE)) {
|
||||
throw Failure{"computer.no_app", "no application with pid " + std::to_string(pid) + " is running"};
|
||||
}
|
||||
Level known = Level::Unknown, mineKnown = Level::Unknown;
|
||||
DWORD theirs = p.h ? integrity(p.h, known) : 0;
|
||||
DWORD mine = integrity(GetCurrentProcess(), mineKnown);
|
||||
if (unreachable || known == Level::Denied || (known == Level::Known && mineKnown == Level::Known && theirs > mine)) {
|
||||
throw Failure{"computer.elevated",
|
||||
"this application runs as administrator and Reasonix Studio does not; Windows keeps a process from reading or "
|
||||
"sending input to one with more privilege. Ask the person to restart it without administrator rights, or to do this step themselves"};
|
||||
}
|
||||
}
|
||||
|
||||
bool ownsPoint(DWORD pid, POINT at) {
|
||||
HWND hit = WindowFromPoint(at);
|
||||
return hit && contentPid(GetAncestor(hit, GA_ROOT)) == pid;
|
||||
}
|
||||
|
||||
bool isFront(DWORD pid) {
|
||||
HWND fg = GetForegroundWindow();
|
||||
return fg && contentPid(fg) == pid;
|
||||
}
|
||||
|
||||
// heldOwner is the window of the same process that window, as a modal dialog,
|
||||
// has disabled somewhere up its owner chain; null when it holds none.
|
||||
HWND heldOwner(HWND window, DWORD pid) {
|
||||
if (!IsWindowEnabled(window)) return nullptr;
|
||||
for (HWND o = GetWindow(window, GW_OWNER); o; o = GetWindow(o, GW_OWNER)) {
|
||||
if (contentPid(o) == pid && !IsWindowEnabled(o)) return o;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// front brings the application forward before input that goes wherever the
|
||||
// foreground is. Windows grants the foreground only to a process that received
|
||||
// the last input, so an input with no effect is injected first.
|
||||
void front(DWORD pid) {
|
||||
if (isFront(pid)) return;
|
||||
std::vector<HWND> wins = appWindows(pid, true);
|
||||
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window to bring to the front"};
|
||||
HWND target = wins[0];
|
||||
if (IsIconic(target)) ShowWindow(target, SW_RESTORE);
|
||||
INPUT nudge{};
|
||||
nudge.type = INPUT_MOUSE;
|
||||
nudge.mi.dwFlags = MOUSEEVENTF_MOVE;
|
||||
SendInput(1, &nudge, sizeof nudge);
|
||||
SetForegroundWindow(target);
|
||||
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
if (!isFront(pid)) {
|
||||
DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
||||
DWORD me = GetCurrentThreadId();
|
||||
if (fgThread && fgThread != me && AttachThreadInput(me, fgThread, TRUE)) {
|
||||
BringWindowToTop(target);
|
||||
SetForegroundWindow(target);
|
||||
AttachThreadInput(me, fgThread, FALSE);
|
||||
}
|
||||
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
if (!isFront(pid)) {
|
||||
throw Failure{"computer.needs_front",
|
||||
"Windows kept this application from coming to the front; ask the person to click it once, then try again"};
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
||||
}
|
||||
|
||||
RECT windowBounds(HWND hwnd) {
|
||||
RECT r{};
|
||||
if (FAILED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &r, sizeof r))) GetWindowRect(hwnd, &r);
|
||||
return r;
|
||||
}
|
||||
|
||||
Json rectJson(const RECT& r) {
|
||||
return Json::object()
|
||||
.set("x", static_cast<long>(r.left))
|
||||
.set("y", static_cast<long>(r.top))
|
||||
.set("width", static_cast<long>(r.right - r.left))
|
||||
.set("height", static_cast<long>(r.bottom - r.top));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
// screenLocked is whether the input desktop is somewhere other than the
|
||||
// person's own: the lock screen and the secure desktop both take it, and while
|
||||
// either does no input or capture reaches an application.
|
||||
bool screenLocked() {
|
||||
HDESK desk = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (!desk) return true;
|
||||
wchar_t name[64] = {};
|
||||
DWORD needed = 0;
|
||||
bool own = GetUserObjectInformationW(desk, UOI_NAME, name, sizeof name, &needed) && _wcsicmp(name, L"Default") == 0;
|
||||
CloseDesktop(desk);
|
||||
return !own;
|
||||
}
|
||||
|
||||
// Windows asks for no grant before one process reads another's accessibility
|
||||
// tree or captures its window, so both are always on here.
|
||||
Json status() {
|
||||
return Json::object().set("accessibility", true).set("screen_recording", true).set("screen_locked", screenLocked());
|
||||
}
|
||||
@@ -1,679 +0,0 @@
|
||||
#include "helper.h"
|
||||
|
||||
#include <ole2.h>
|
||||
#include <UIAutomation.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
using Microsoft::WRL::ComPtr;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int maxNodes = 1500;
|
||||
constexpr int maxDepth = 40;
|
||||
// A value longer than this is not read back: comparing it costs more than the
|
||||
// step it would grade.
|
||||
constexpr int maxReadback = 1 << 16;
|
||||
constexpr HRESULT elementGone = static_cast<HRESULT>(0x80040201); // UIA_E_ELEMENTNOTAVAILABLE
|
||||
constexpr HRESULT elementDisabled = static_cast<HRESULT>(0x80040200); // UIA_E_ELEMENTNOTENABLED
|
||||
constexpr HRESULT notSupported = static_cast<HRESULT>(0x80040204); // UIA_E_NOTSUPPORTED
|
||||
|
||||
ComPtr<IUIAutomation> uia;
|
||||
ComPtr<IUIAutomationTreeWalker> walker;
|
||||
ComPtr<IUIAutomationCacheRequest> cache;
|
||||
|
||||
void need() {
|
||||
if (!uia) throw Failure{"computer.unsupported", "UI Automation is not available on this system"};
|
||||
}
|
||||
|
||||
std::string hresult(HRESULT hr) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof buf, "0x%08lx", static_cast<unsigned long>(hr));
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string text(const VARIANT& v) {
|
||||
return v.vt == VT_BSTR && v.bstrVal ? narrow(std::wstring(v.bstrVal, SysStringLen(v.bstrVal))) : "";
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
VARIANT v;
|
||||
Cached(IUIAutomationElement* el, PROPERTYID id) {
|
||||
VariantInit(&v);
|
||||
el->GetCachedPropertyValue(id, &v);
|
||||
}
|
||||
~Cached() { VariantClear(&v); }
|
||||
bool flag() const { return v.vt == VT_BOOL && v.boolVal == VARIANT_TRUE; }
|
||||
int integer() const { return v.vt == VT_I4 ? v.lVal : 0; }
|
||||
std::string str() const { return text(v); }
|
||||
};
|
||||
|
||||
// runtimeKey is an element's identity for as long as it exists: the API hands
|
||||
// back a new pointer for the same element, but not a new runtime id.
|
||||
std::string runtimeKey(IUIAutomationElement* el) {
|
||||
Cached id(el, UIA_RuntimeIdPropertyId);
|
||||
if (id.v.vt != (VT_I4 | VT_ARRAY) || !id.v.parray) return {};
|
||||
LONG lo = 0, hi = -1;
|
||||
SafeArrayGetLBound(id.v.parray, 1, &lo);
|
||||
SafeArrayGetUBound(id.v.parray, 1, &hi);
|
||||
std::string key;
|
||||
for (LONG i = lo; i <= hi; i++) {
|
||||
int part = 0;
|
||||
SafeArrayGetElement(id.v.parray, &i, &part);
|
||||
key += std::to_string(part) + ".";
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
// Refs name elements for the model. One is issued per element and never
|
||||
// reused, so a ref read earlier cannot come to mean something else.
|
||||
struct Entry {
|
||||
DWORD pid;
|
||||
ComPtr<IUIAutomationElement> el;
|
||||
};
|
||||
int nextRef = 0;
|
||||
std::map<std::string, std::string> refByKey;
|
||||
std::map<std::string, Entry> byRef;
|
||||
|
||||
std::string refFor(IUIAutomationElement* el, DWORD pid) {
|
||||
std::string key = runtimeKey(el);
|
||||
if (!key.empty()) {
|
||||
auto found = refByKey.find(std::to_string(pid) + ":" + key);
|
||||
if (found != refByKey.end()) return found->second;
|
||||
}
|
||||
std::string ref = "a" + std::to_string(++nextRef);
|
||||
if (!key.empty()) refByKey[std::to_string(pid) + ":" + key] = ref;
|
||||
byRef[ref] = Entry{pid, el};
|
||||
return ref;
|
||||
}
|
||||
|
||||
ComPtr<IUIAutomationElement> element(const std::string& ref, DWORD pid) {
|
||||
auto found = byRef.find(ref);
|
||||
if (found == byRef.end()) throw Failure{"computer.unknown_ref", ref + " was never issued; refs come from a snapshot"};
|
||||
if (found->second.pid != pid) throw Failure{"computer.unknown_ref", ref + " belongs to another application"};
|
||||
int alive = 0;
|
||||
if (FAILED(found->second.el->get_CurrentProcessId(&alive))) {
|
||||
throw Failure{"computer.stale_ref", ref + " is no longer on screen; take a new snapshot"};
|
||||
}
|
||||
return found->second.el;
|
||||
}
|
||||
|
||||
std::string roleName(int type) {
|
||||
static const char* names[] = {
|
||||
"button", "calendar", "checkBox", "comboBox", "edit", "hyperlink", "image", "listItem", "list", "menu",
|
||||
"menuBar", "menuItem", "progressBar", "radioButton", "scrollBar", "slider", "spinner", "statusBar", "tab",
|
||||
"tabItem", "text", "toolBar", "toolTip", "tree", "treeItem", "custom", "group", "thumb", "dataGrid",
|
||||
"dataItem", "document", "splitButton", "window", "pane", "header", "headerItem", "table", "titleBar",
|
||||
"separator", "semanticZoom", "appBar",
|
||||
};
|
||||
int i = type - UIA_ButtonControlTypeId;
|
||||
return i >= 0 && i < static_cast<int>(std::size(names)) ? names[i] : "element";
|
||||
}
|
||||
|
||||
std::string clipQuote(const std::string& s) {
|
||||
std::wstring w = widen(s);
|
||||
if (w.size() > 160) {
|
||||
size_t cut = 160;
|
||||
if (IS_HIGH_SURROGATE(w[cut - 1])) cut--;
|
||||
return quote(narrow(w.substr(0, cut)) + "\xE2\x80\xA6");
|
||||
}
|
||||
return quote(s);
|
||||
}
|
||||
|
||||
bool pressable(IUIAutomationElement* el) {
|
||||
return Cached(el, UIA_IsInvokePatternAvailablePropertyId).flag() || Cached(el, UIA_IsTogglePatternAvailablePropertyId).flag() ||
|
||||
Cached(el, UIA_IsExpandCollapsePatternAvailablePropertyId).flag() ||
|
||||
Cached(el, UIA_IsSelectionItemPatternAvailablePropertyId).flag() ||
|
||||
!Cached(el, UIA_LegacyIAccessibleDefaultActionPropertyId).str().empty();
|
||||
}
|
||||
|
||||
// Containers that say nothing when unnamed: their children stand in for them.
|
||||
bool transparent(int type) {
|
||||
return type == UIA_GroupControlTypeId || type == UIA_PaneControlTypeId || type == UIA_CustomControlTypeId;
|
||||
}
|
||||
|
||||
std::string windowTitle(HWND hwnd) {
|
||||
wchar_t buf[512];
|
||||
int n = GetWindowTextW(hwnd, buf, ARRAYSIZE(buf));
|
||||
return narrow(std::wstring(buf, n > 0 ? n : 0));
|
||||
}
|
||||
|
||||
HWND cachedHandle(IUIAutomationElement* el) {
|
||||
return reinterpret_cast<HWND>(static_cast<intptr_t>(Cached(el, UIA_NativeWindowHandlePropertyId).integer()));
|
||||
}
|
||||
|
||||
// inWindowModal is a dialog drawn inside its window that declares itself
|
||||
// modal. A top-level window's own root does not count: one holds input only
|
||||
// through the owner it has disabled.
|
||||
bool inWindowModal(IUIAutomationElement* el, HWND root) {
|
||||
return Cached(el, UIA_ControlTypePropertyId).integer() == UIA_WindowControlTypeId &&
|
||||
Cached(el, UIA_WindowIsModalPropertyId).flag() && cachedHandle(el) != root;
|
||||
}
|
||||
|
||||
// modalJson names a modal for the model; the titles are shown, never read.
|
||||
Json modalJson(IUIAutomationElement* el, DWORD pid, const std::string& blocks) {
|
||||
return Json::object().set("ref", refFor(el, pid)).set("title", Cached(el, UIA_NamePropertyId).str()).set("blocks", blocks);
|
||||
}
|
||||
|
||||
// win32Modal is the modal dialog hwnd is, when it holds another window of
|
||||
// the process; null otherwise.
|
||||
Json win32Modal(HWND hwnd, DWORD pid) {
|
||||
HWND held = heldOwner(hwnd, pid);
|
||||
if (!held) return Json();
|
||||
ComPtr<IUIAutomationElement> el;
|
||||
if (FAILED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &el)) || !el) {
|
||||
return Json::object().set("ref", "").set("title", windowTitle(hwnd)).set("blocks", windowTitle(held));
|
||||
}
|
||||
return modalJson(el.Get(), pid, windowTitle(held));
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
DWORD pid;
|
||||
HWND root = nullptr;
|
||||
bool rootIsModal = false;
|
||||
std::string rootTitle, heldTitle;
|
||||
Json modals = Json::array();
|
||||
std::set<std::string> modalRefs;
|
||||
std::vector<std::string> lines;
|
||||
std::set<std::string> seen;
|
||||
int count = 0;
|
||||
// A tree that hands back elements already seen, or nests unnamed
|
||||
// containers without end, is bounded by work done, not by lines written.
|
||||
int steps = 0;
|
||||
int nesting = 0;
|
||||
static constexpr int maxSteps = maxNodes * 4;
|
||||
static constexpr int maxNesting = 200;
|
||||
|
||||
void children(IUIAutomationElement* el, int depth) {
|
||||
ComPtr<IUIAutomationElement> child;
|
||||
walker->GetFirstChildElementBuildCache(el, cache.Get(), &child);
|
||||
while (child && count < maxNodes && ++steps < maxSteps) {
|
||||
visit(child.Get(), depth);
|
||||
ComPtr<IUIAutomationElement> next;
|
||||
walker->GetNextSiblingElementBuildCache(child.Get(), cache.Get(), &next);
|
||||
child = next;
|
||||
}
|
||||
}
|
||||
|
||||
void visit(IUIAutomationElement* el, int depth) {
|
||||
if (count >= maxNodes || depth > maxDepth || nesting >= maxNesting) return;
|
||||
struct Nest { int& n; Nest(int& n) : n(++n) {} ~Nest() { --n; } } nest(nesting);
|
||||
std::string key = runtimeKey(el);
|
||||
if (!key.empty() && !seen.insert(key).second) return;
|
||||
count++;
|
||||
int type = Cached(el, UIA_ControlTypePropertyId).integer();
|
||||
std::string label = Cached(el, UIA_NamePropertyId).str();
|
||||
bool password = Cached(el, UIA_IsPasswordPropertyId).flag();
|
||||
std::string value = password ? "" : Cached(el, UIA_ValueValuePropertyId).str();
|
||||
if (label.empty() && type == UIA_TextControlTypeId) label = value;
|
||||
if (transparent(type) && label.empty()) {
|
||||
children(el, depth);
|
||||
return;
|
||||
}
|
||||
std::string line(static_cast<size_t>(depth) * 2, ' ');
|
||||
line += "- " + roleName(type);
|
||||
if (!label.empty()) line += " " + clipQuote(label);
|
||||
line += " [" + refFor(el, pid) + "]";
|
||||
if (!value.empty() && value != label && type != UIA_TextControlTypeId) line += " value=" + clipQuote(value);
|
||||
std::string help = Cached(el, UIA_HelpTextPropertyId).str();
|
||||
if (value.empty() && !help.empty() && (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId)) {
|
||||
line += " placeholder=" + clipQuote(help);
|
||||
}
|
||||
if (password) line += " password";
|
||||
if (Cached(el, UIA_HasKeyboardFocusPropertyId).flag()) line += " focused";
|
||||
if (!Cached(el, UIA_IsEnabledPropertyId).flag()) line += " disabled";
|
||||
if (Cached(el, UIA_SelectionItemIsSelectedPropertyId).flag()) line += " selected";
|
||||
if (Cached(el, UIA_ToggleToggleStatePropertyId).integer() == ToggleState_On) line += " checked";
|
||||
if (pressable(el)) line += " pressable";
|
||||
bool isRoot = cachedHandle(el) == root;
|
||||
if ((isRoot && rootIsModal) || inWindowModal(el, root)) {
|
||||
line += " modal";
|
||||
Json m = modalJson(el, pid, isRoot ? heldTitle : rootTitle);
|
||||
if (modalRefs.insert(m.get("ref")->string()).second) modals.push(m);
|
||||
}
|
||||
lines.push_back(line);
|
||||
children(el, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// settle reads what an accessibility call's result says about the element.
|
||||
void settle(HRESULT hr, const std::string& what, const char* action) {
|
||||
if (SUCCEEDED(hr)) return;
|
||||
if (hr == elementGone) throw Failure{"computer.stale_ref", what + " is no longer on screen; take a new snapshot"};
|
||||
if (hr == elementDisabled) throw Failure{"computer.no_action", what + " is disabled"};
|
||||
if (hr == notSupported) throw Failure{"computer.no_action", what + " takes no " + action};
|
||||
throw Failure{"computer.failed", std::string(action) + " on " + what + " failed (HRESULT " + hresult(hr) + ")"};
|
||||
}
|
||||
|
||||
// detached runs an action that can block until the application finishes what
|
||||
// it starts — an invoke that opens a modal dialog returns when the dialog
|
||||
// closes. Past a short wait the action has been delivered, which is the answer.
|
||||
HRESULT detached(std::function<HRESULT()> action) {
|
||||
auto done = std::make_shared<std::promise<HRESULT>>();
|
||||
std::future<HRESULT> result = done->get_future();
|
||||
std::thread([action = std::move(action), done] {
|
||||
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||
done->set_value(action());
|
||||
CoUninitialize();
|
||||
}).detach();
|
||||
if (result.wait_for(std::chrono::milliseconds(1500)) == std::future_status::timeout) return S_OK;
|
||||
return result.get();
|
||||
}
|
||||
|
||||
template <typename P>
|
||||
ComPtr<P> pattern(IUIAutomationElement* el, PATTERNID id) {
|
||||
ComPtr<P> p;
|
||||
el->GetCurrentPatternAs(id, IID_PPV_ARGS(&p));
|
||||
return p;
|
||||
}
|
||||
|
||||
// activate does what a click on the element would: invoke it, or toggle,
|
||||
// expand, select, or its accessible default action, in that order.
|
||||
void activate(IUIAutomationElement* el, const std::string& what) {
|
||||
if (auto p = pattern<IUIAutomationInvokePattern>(el, UIA_InvokePatternId)) {
|
||||
return settle(detached([p] { return p->Invoke(); }), what, "invoke");
|
||||
}
|
||||
if (auto p = pattern<IUIAutomationTogglePattern>(el, UIA_TogglePatternId)) {
|
||||
return settle(detached([p] { return p->Toggle(); }), what, "toggle");
|
||||
}
|
||||
if (auto p = pattern<IUIAutomationExpandCollapsePattern>(el, UIA_ExpandCollapsePatternId)) {
|
||||
ExpandCollapseState state = ExpandCollapseState_LeafNode;
|
||||
p->get_CurrentExpandCollapseState(&state);
|
||||
bool open = state == ExpandCollapseState_Collapsed;
|
||||
return settle(detached([p, open] { return open ? p->Expand() : p->Collapse(); }), what, "expand");
|
||||
}
|
||||
if (auto p = pattern<IUIAutomationSelectionItemPattern>(el, UIA_SelectionItemPatternId)) {
|
||||
return settle(detached([p] { return p->Select(); }), what, "select");
|
||||
}
|
||||
if (auto p = pattern<IUIAutomationLegacyIAccessiblePattern>(el, UIA_LegacyIAccessiblePatternId)) {
|
||||
BSTR action = nullptr;
|
||||
p->get_CurrentDefaultAction(&action);
|
||||
bool has = action && SysStringLen(action) > 0;
|
||||
SysFreeString(action);
|
||||
if (has) return settle(detached([p] { return p->DoDefaultAction(); }), what, "default action");
|
||||
}
|
||||
throw Failure{"computer.no_action", what + " takes no press"};
|
||||
}
|
||||
|
||||
// showAt puts the agent's cursor on an element before it is acted on.
|
||||
void showAt(IUIAutomationElement* el) {
|
||||
RECT r{};
|
||||
if (SUCCEEDED(el->get_CurrentBoundingRectangle(&r)) && r.right > r.left && r.bottom > r.top) {
|
||||
cursorMove(POINT{(r.left + r.right) / 2, (r.top + r.bottom) / 2});
|
||||
}
|
||||
}
|
||||
|
||||
void checkPoint(DWORD pid, POINT at) {
|
||||
std::string where = "(" + std::to_string(at.x) + ", " + std::to_string(at.y) + ")";
|
||||
HWND hit = WindowFromPoint(at);
|
||||
if (!hit || contentPid(GetAncestor(hit, GA_ROOT)) != pid) {
|
||||
throw Failure{"computer.no_element", hit ? "the element at " + where + " belongs to another application"
|
||||
: "nothing of this application is at " + where};
|
||||
}
|
||||
}
|
||||
|
||||
// scrollable is the nearest element, from el outwards, that scrolls vertically.
|
||||
ComPtr<IUIAutomationScrollPattern> scrollable(ComPtr<IUIAutomationElement> el) {
|
||||
for (int i = 0; el && i < maxDepth; i++) {
|
||||
if (auto p = pattern<IUIAutomationScrollPattern>(el.Get(), UIA_ScrollPatternId)) {
|
||||
BOOL can = FALSE;
|
||||
if (SUCCEEDED(p->get_CurrentVerticallyScrollable(&can)) && can) return p;
|
||||
}
|
||||
ComPtr<IUIAutomationElement> parent;
|
||||
walker->GetParentElement(el.Get(), &parent);
|
||||
el = parent;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// wheel turns the wheel over the application's front window without the
|
||||
// pointer: the message goes to the innermost window at its centre.
|
||||
void wheel(DWORD pid, double amount) {
|
||||
std::vector<HWND> wins = appWindows(pid, false);
|
||||
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window on screen to scroll"};
|
||||
RECT r = windowBounds(wins[0]);
|
||||
POINT centre{(r.left + r.right) / 2, (r.top + r.bottom) / 2};
|
||||
HWND target = wins[0];
|
||||
for (int i = 0; i < maxDepth; i++) {
|
||||
POINT local = centre;
|
||||
ScreenToClient(target, &local);
|
||||
HWND child = ChildWindowFromPointEx(target, local, CWP_SKIPINVISIBLE | CWP_SKIPDISABLED | CWP_SKIPTRANSPARENT);
|
||||
if (!child || child == target) break;
|
||||
target = child;
|
||||
}
|
||||
int delta = static_cast<int>(amount * WHEEL_DELTA / 3);
|
||||
if (delta > -WHEEL_DELTA && delta < WHEEL_DELTA) delta = amount < 0 ? -WHEEL_DELTA : WHEEL_DELTA;
|
||||
PostMessageW(target, WM_MOUSEWHEEL, MAKEWPARAM(0, static_cast<SHORT>(delta)), MAKELPARAM(centre.x, centre.y));
|
||||
}
|
||||
|
||||
// nativeRoot is the top-level window an element is drawn in.
|
||||
HWND nativeRoot(IUIAutomationElement* el) {
|
||||
ComPtr<IUIAutomationElement> at = el;
|
||||
for (int i = 0; at && i < maxDepth; i++) {
|
||||
UIA_HWND h = nullptr;
|
||||
if (SUCCEEDED(at->get_CurrentNativeWindowHandle(&h)) && h) return GetAncestor(static_cast<HWND>(h), GA_ROOT);
|
||||
ComPtr<IUIAutomationElement> parent;
|
||||
walker->GetParentElement(at.Get(), &parent);
|
||||
at = parent;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// reachable refuses an element in a window the application has disabled,
|
||||
// which a modal dialog does to the window it holds: the application has
|
||||
// declared that window takes no input until the dialog is answered.
|
||||
void reachable(IUIAutomationElement* el, DWORD pid, const std::string& what) {
|
||||
HWND root = nativeRoot(el);
|
||||
if (!root || IsWindowEnabled(root)) return;
|
||||
for (HWND w : appWindows(pid, false)) {
|
||||
for (HWND o = heldOwner(w, pid) ? GetWindow(w, GW_OWNER) : nullptr; o; o = GetWindow(o, GW_OWNER)) {
|
||||
if (o == root) {
|
||||
throw Failure{"computer.blocked", what + " is in a window a modal dialog holds; answer or dismiss the dialog first",
|
||||
win32Modal(w, pid)};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Failure{"computer.blocked", what + " is in a window the application has disabled"};
|
||||
}
|
||||
|
||||
// typedExactly is whether after is before with typed put in at one place,
|
||||
// replacing whatever was selected there. Where repeated characters make the
|
||||
// changed span ambiguous it is taken from either end, and any other change,
|
||||
// such as autocomplete or a single-line field dropping a newline, is not it.
|
||||
bool typedExactly(const std::wstring& before, const std::wstring& after, const std::wstring& typed) {
|
||||
if (after == before || after.size() < typed.size()) return false;
|
||||
auto span = [&](bool prefixFirst) {
|
||||
size_t p = 0, s = 0, room = std::min(before.size(), after.size());
|
||||
auto prefix = [&] { while (p < room - s && before[p] == after[p]) p++; };
|
||||
auto suffix = [&] { while (s < room - p && before[before.size() - 1 - s] == after[after.size() - 1 - s]) s++; };
|
||||
if (prefixFirst) {
|
||||
prefix();
|
||||
suffix();
|
||||
} else {
|
||||
suffix();
|
||||
prefix();
|
||||
}
|
||||
return after.substr(p, after.size() - p - s);
|
||||
};
|
||||
return span(true) == typed || span(false) == typed;
|
||||
}
|
||||
|
||||
std::wstring flat(std::wstring s) {
|
||||
s.erase(std::remove(s.begin(), s.end(), L'\r'), s.end());
|
||||
return s;
|
||||
}
|
||||
|
||||
// readValue is what an element holds as text, for comparing before and after
|
||||
// a step. A password, or a value too long to compare, is not read.
|
||||
bool readValue(IUIAutomationElement* el, std::wstring& out) {
|
||||
BOOL password = FALSE;
|
||||
if (FAILED(el->get_CurrentIsPassword(&password)) || password) return false;
|
||||
BSTR v = nullptr;
|
||||
if (auto p = pattern<IUIAutomationValuePattern>(el, UIA_ValuePatternId)) {
|
||||
if (FAILED(p->get_CurrentValue(&v))) return false;
|
||||
} else if (auto t = pattern<IUIAutomationTextPattern>(el, UIA_TextPatternId)) {
|
||||
ComPtr<IUIAutomationTextRange> doc;
|
||||
if (FAILED(t->get_DocumentRange(&doc)) || !doc || FAILED(doc->GetText(maxReadback, &v))) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
out = flat(v ? std::wstring(v, SysStringLen(v)) : std::wstring());
|
||||
SysFreeString(v);
|
||||
return out.size() < static_cast<size_t>(maxReadback);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Json effect(const char* cls, const char* evidence, const Json& modal) {
|
||||
Json e = Json::object().set("class", cls);
|
||||
if (evidence) {
|
||||
Json kinds = Json::array();
|
||||
kinds.push(evidence);
|
||||
e.set("evidence", kinds);
|
||||
}
|
||||
if (modal.kind() != Json::Kind::Null) e.set("blocked_by", modal);
|
||||
return Json::object().set("effect", e);
|
||||
}
|
||||
|
||||
Landing landing(DWORD pid, bool withValue) {
|
||||
need();
|
||||
Landing at;
|
||||
ComPtr<IUIAutomationElement> focused;
|
||||
int owner = 0;
|
||||
if (FAILED(uia->GetFocusedElementBuildCache(cache.Get(), &focused)) || !focused || FAILED(focused->get_CurrentProcessId(&owner)) ||
|
||||
static_cast<DWORD>(owner) != pid) {
|
||||
focused = nullptr;
|
||||
}
|
||||
HWND root = focused ? nativeRoot(focused.Get()) : nullptr;
|
||||
if (!root && isFront(pid)) root = GetForegroundWindow();
|
||||
ComPtr<IUIAutomationElement> el = focused;
|
||||
for (int i = 0; el && i < maxDepth && at.modal.kind() == Json::Kind::Null; i++) {
|
||||
if (inWindowModal(el.Get(), root)) at.modal = modalJson(el.Get(), pid, windowTitle(root));
|
||||
ComPtr<IUIAutomationElement> parent;
|
||||
walker->GetParentElementBuildCache(el.Get(), cache.Get(), &parent);
|
||||
el = parent;
|
||||
}
|
||||
if (at.modal.kind() == Json::Kind::Null && root) at.modal = win32Modal(root, pid);
|
||||
if (!focused || !withValue) return at;
|
||||
if (Cached(focused.Get(), UIA_IsValuePatternAvailablePropertyId).flag()) {
|
||||
at.takesText = !Cached(focused.Get(), UIA_ValueIsReadOnlyPropertyId).flag();
|
||||
} else {
|
||||
int type = Cached(focused.Get(), UIA_ControlTypePropertyId).integer();
|
||||
at.takesText = type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId;
|
||||
}
|
||||
at.read = [focused](std::wstring& out) { return readValue(focused.Get(), out); };
|
||||
at.readable = at.read(at.before);
|
||||
return at;
|
||||
}
|
||||
|
||||
void refuseHeldText(const Landing& at) {
|
||||
if (at.modal.kind() == Json::Kind::Null || at.takesText) return;
|
||||
throw Failure{"computer.blocked",
|
||||
"a modal dialog holds this application's input and its focused element takes no text, where a letter can answer the "
|
||||
"dialog; answer or dismiss it first",
|
||||
at.modal};
|
||||
}
|
||||
|
||||
// The application takes typed characters one message at a time, so a value
|
||||
// read part-way through holds some of them; it is read again until all are in.
|
||||
Json typedEffect(const Landing& at, const std::wstring& typed) {
|
||||
if (!at.readable) return effect("unverifiable", nullptr, at.modal);
|
||||
std::wstring after, want = flat(typed);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (!at.read(after)) return effect("unverifiable", nullptr, at.modal);
|
||||
if (typedExactly(at.before, after, want)) return effect("confirmed", "value_readback", at.modal);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
}
|
||||
if (after == at.before) return effect("suspected_noop", "value_unchanged", at.modal);
|
||||
return effect("unverifiable", nullptr, at.modal);
|
||||
}
|
||||
|
||||
void uiaStart() {
|
||||
if (FAILED(CoCreateInstance(__uuidof(CUIAutomation8), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia))) &&
|
||||
FAILED(CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia)))) {
|
||||
return;
|
||||
}
|
||||
// A hung application must not hang the helper with it.
|
||||
ComPtr<IUIAutomation2> timed;
|
||||
if (SUCCEEDED(uia.As(&timed))) {
|
||||
timed->put_ConnectionTimeout(2000);
|
||||
timed->put_TransactionTimeout(5000);
|
||||
}
|
||||
uia->get_ControlViewWalker(&walker);
|
||||
uia->CreateCacheRequest(&cache);
|
||||
for (PROPERTYID id : {UIA_RuntimeIdPropertyId, UIA_ControlTypePropertyId, UIA_NamePropertyId, UIA_ValueValuePropertyId,
|
||||
UIA_IsPasswordPropertyId, UIA_HelpTextPropertyId, UIA_HasKeyboardFocusPropertyId, UIA_IsEnabledPropertyId,
|
||||
UIA_SelectionItemIsSelectedPropertyId, UIA_ToggleToggleStatePropertyId, UIA_IsInvokePatternAvailablePropertyId,
|
||||
UIA_IsTogglePatternAvailablePropertyId, UIA_IsExpandCollapsePatternAvailablePropertyId,
|
||||
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId,
|
||||
UIA_WindowIsModalPropertyId, UIA_NativeWindowHandlePropertyId, UIA_IsValuePatternAvailablePropertyId,
|
||||
UIA_ValueIsReadOnlyPropertyId}) {
|
||||
cache->AddProperty(id);
|
||||
}
|
||||
}
|
||||
|
||||
Json snapshot(DWORD pid) {
|
||||
need();
|
||||
Walk walk{pid};
|
||||
std::string note;
|
||||
std::vector<HWND> wins = appWindows(pid, false);
|
||||
if (wins.empty()) {
|
||||
note = screenLocked()
|
||||
? "The screen is locked. While it is, no application's window can be read, clicked by ref or hit by point; unlocking the screen brings it back."
|
||||
: "This application has no window on screen right now — it may be minimized. A type, key or pointer step brings it forward; take a new snapshot after.";
|
||||
}
|
||||
for (HWND hwnd : wins) {
|
||||
walk.root = hwnd;
|
||||
HWND held = heldOwner(hwnd, pid);
|
||||
walk.rootIsModal = held != nullptr;
|
||||
walk.rootTitle = windowTitle(hwnd);
|
||||
walk.heldTitle = held ? windowTitle(held) : "";
|
||||
ComPtr<IUIAutomationElement> root;
|
||||
if (SUCCEEDED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &root)) && root) walk.visit(root.Get(), 0);
|
||||
}
|
||||
Json lines = Json::array();
|
||||
for (auto& l : walk.lines) lines.push(std::move(l));
|
||||
return Json::object()
|
||||
.set("app", displayName(pid))
|
||||
.set("bundle", identity(pid))
|
||||
.set("lines", lines)
|
||||
.set("truncated", walk.count >= maxNodes)
|
||||
.set("note", note)
|
||||
.set("modals", walk.modals);
|
||||
}
|
||||
|
||||
Json press(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
activate(el.Get(), ref);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
// clickAt is a press at a point: the element there is asked for its action,
|
||||
// so the person's pointer never moves. One with no action says so rather than
|
||||
// being clicked some other way.
|
||||
Json clickAt(DWORD pid, double x, double y) {
|
||||
need();
|
||||
POINT at{static_cast<LONG>(x), static_cast<LONG>(y)};
|
||||
cursorMove(at);
|
||||
// Checked after the glide, just before the lookup: a window that came up
|
||||
// over the point meanwhile is not the application that was approved.
|
||||
checkPoint(pid, at);
|
||||
ComPtr<IUIAutomationElement> el;
|
||||
if (FAILED(uia->ElementFromPointBuildCache(at, cache.Get(), &el)) || !el) {
|
||||
throw Failure{"computer.no_element", "nothing of this application is at (" + num(x) + ", " + num(y) + ")"};
|
||||
}
|
||||
int owner = 0;
|
||||
DWORD framePid = 0;
|
||||
GetWindowThreadProcessId(GetAncestor(WindowFromPoint(at), GA_ROOT), &framePid);
|
||||
if (FAILED(el->get_CurrentProcessId(&owner)) || (static_cast<DWORD>(owner) != pid && static_cast<DWORD>(owner) != framePid)) {
|
||||
throw Failure{"computer.no_element", "the element at (" + num(x) + ", " + num(y) + ") belongs to another application"};
|
||||
}
|
||||
int type = Cached(el.Get(), UIA_ControlTypePropertyId).integer();
|
||||
std::string what = "the " + roleName(type) + " at that point";
|
||||
reachable(el.Get(), pid, what);
|
||||
if (pressable(el.Get())) {
|
||||
activate(el.Get(), what);
|
||||
} else if (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId || type == UIA_DocumentControlTypeId) {
|
||||
settle(el->SetFocus(), what, "focus");
|
||||
} else {
|
||||
throw Failure{"computer.no_action", what + " takes no accessibility action; it needs the real pointer"};
|
||||
}
|
||||
return Json::object().set("role", roleName(type));
|
||||
}
|
||||
|
||||
// menu asks an element for its context menu, which is what a right click is
|
||||
// for. Read the application again afterwards to see the menu.
|
||||
Json menu(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> target = element(ref, pid);
|
||||
reachable(target.Get(), pid, ref);
|
||||
showAt(target.Get());
|
||||
ComPtr<IUIAutomationElement3> el;
|
||||
if (FAILED(target.As(&el))) throw Failure{"computer.no_action", ref + " has no context menu"};
|
||||
HRESULT hr = detached([el] { return el->ShowContextMenu(); });
|
||||
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no context menu"};
|
||||
settle(hr, ref, "context menu");
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json scroll(DWORD pid, const std::string& ref, double amount) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> from;
|
||||
if (!ref.empty()) {
|
||||
from = element(ref, pid);
|
||||
reachable(from.Get(), pid, ref);
|
||||
showAt(from.Get());
|
||||
if (amount == 0) {
|
||||
BOOL offscreen = TRUE;
|
||||
if (SUCCEEDED(from->get_CurrentIsOffscreen(&offscreen)) && !offscreen) return Json::object().set("how", "revealed");
|
||||
if (auto p = pattern<IUIAutomationScrollItemPattern>(from.Get(), UIA_ScrollItemPatternId)) {
|
||||
settle(p->ScrollIntoView(), ref, "scroll into view");
|
||||
return Json::object().set("how", "revealed");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (amount == 0) {
|
||||
throw Failure{"computer.bad_step", "a scroll needs a ref that can be revealed, or lines to turn the wheel by"};
|
||||
}
|
||||
if (!from) {
|
||||
ComPtr<IUIAutomationElement> focused;
|
||||
int owner = 0;
|
||||
if (SUCCEEDED(uia->GetFocusedElement(&focused)) && focused && SUCCEEDED(focused->get_CurrentProcessId(&owner)) &&
|
||||
static_cast<DWORD>(owner) == pid) {
|
||||
from = focused;
|
||||
}
|
||||
}
|
||||
if (auto p = scrollable(from)) {
|
||||
ScrollAmount step = amount < 0 ? ScrollAmount_SmallIncrement : ScrollAmount_SmallDecrement;
|
||||
for (int i = 0; i < static_cast<int>(std::abs(amount)); i++) settle(p->Scroll(ScrollAmount_NoAmount, step), "the view", "scroll");
|
||||
} else {
|
||||
wheel(pid, amount);
|
||||
}
|
||||
return Json::object().set("how", "wheel");
|
||||
}
|
||||
|
||||
Json focus(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
HRESULT hr = el->SetFocus();
|
||||
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " cannot take focus (HRESULT " + hresult(hr) + ")"};
|
||||
settle(hr, ref, "focus");
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json setValue(DWORD pid, const std::string& ref, const std::string& value) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
auto p = pattern<IUIAutomationValuePattern>(el.Get(), UIA_ValuePatternId);
|
||||
BOOL readOnly = FALSE;
|
||||
if (!p || (SUCCEEDED(p->get_CurrentIsReadOnly(&readOnly)) && readOnly)) {
|
||||
throw Failure{"computer.no_action", ref + " has no value that can be set"};
|
||||
}
|
||||
std::wstring before, now;
|
||||
bool hadBefore = readValue(el.Get(), before);
|
||||
BSTR b = SysAllocString(widen(value).c_str());
|
||||
HRESULT hr = p->SetValue(b);
|
||||
SysFreeString(b);
|
||||
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no value that can be set (HRESULT " + hresult(hr) + ")"};
|
||||
settle(hr, ref, "set value");
|
||||
if (!readValue(el.Get(), now)) return effect("unverifiable", nullptr, Json());
|
||||
if (now == flat(widen(value))) return effect("confirmed", "value_readback", Json());
|
||||
if (hadBefore && now == before) return effect("suspected_noop", "value_unchanged", Json());
|
||||
return effect("unverifiable", nullptr, Json());
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Cua AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -41,13 +41,15 @@ files:
|
||||
- assets/**/*
|
||||
- package.json
|
||||
extraResources:
|
||||
- from: ../../internal/platform/computer/cua-driver.lock.json
|
||||
to: bin/cua-driver.lock.json
|
||||
- from: bin
|
||||
to: bin
|
||||
filter:
|
||||
- reasonix-studio-host
|
||||
- reasonix-studio-host.exe
|
||||
- reasonix-computer-helper
|
||||
- reasonix-computer-helper.exe
|
||||
- cua-driver
|
||||
- cua-driver.exe
|
||||
- from: ../frontend-next/dist
|
||||
to: frontend-next/dist
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"start": "electron .",
|
||||
"build:host": "node packaging/build-host.js",
|
||||
"build:linux-payload": "node packaging/linux-payload.js",
|
||||
"test": "node --test test/unit.mjs",
|
||||
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
|
||||
"build:app": "electron-builder --dir",
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
@@ -20,9 +20,9 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.5.1",
|
||||
"electron": "44.7.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -40,66 +40,8 @@ function go(args, env) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Windows helper is C++ against the system's own UI Automation, input and
|
||||
// imaging APIs, built with whichever MSVC vswhere finds. The CRT is linked in
|
||||
// statically so the helper needs no redistributable on the person's machine.
|
||||
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
|
||||
|
||||
// The helper is built for the architecture the kernel is, with that
|
||||
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
|
||||
// an ARM64 machine (vcvarsarm64 is the native environment there).
|
||||
const WINDOWS_TOOLCHAIN = {
|
||||
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
|
||||
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
|
||||
};
|
||||
|
||||
function msvcVars(toolchain) {
|
||||
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
|
||||
if (!fs.existsSync(vswhere)) return "";
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
|
||||
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
|
||||
return vars && fs.existsSync(vars) ? vars : "";
|
||||
}
|
||||
|
||||
function buildWindowsHelper(goarch) {
|
||||
const toolchain = WINDOWS_TOOLCHAIN[goarch];
|
||||
if (!toolchain) {
|
||||
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const vars = msvcVars(toolchain);
|
||||
if (!vars) {
|
||||
// A release without the helper would ship computer use switched off with
|
||||
// nothing saying why, so CI refuses; a dev build only loses the feature.
|
||||
if (process.env.CI) {
|
||||
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
|
||||
return;
|
||||
}
|
||||
const src = path.join(ROOT, "desktop", "computer-helper", "windows");
|
||||
const objs = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "reasonix-helper-"));
|
||||
const out = path.join(OUT, "reasonix-computer-helper.exe");
|
||||
const libs = "user32.lib gdi32.lib ole32.lib oleaut32.lib uiautomationcore.lib windowscodecs.lib dwmapi.lib version.lib advapi32.lib uuid.lib gdiplus.lib";
|
||||
const script = path.join(objs, "build.cmd");
|
||||
fs.writeFileSync(script, [
|
||||
"@echo off",
|
||||
// vcvars looks for vswhere on PATH and complains when it is not there.
|
||||
`set "PATH=%PATH%;${VS_INSTALLER}"`,
|
||||
`call "${vars}" >nul || exit /b 1`,
|
||||
`cd /d "${src}"`,
|
||||
`cl /nologo /std:c++17 /EHsc /O2 /MT /utf-8 /W4 /DUNICODE /D_UNICODE /Fo"${objs}\\\\" /Fe"${out}" *.cpp /link ${libs}`,
|
||||
].join("\r\n"));
|
||||
const built = spawnSync("cmd.exe", ["/d", "/c", script], { stdio: "inherit", env: { ...process.env, VSLANG: "1033" } });
|
||||
fs.rmSync(objs, { recursive: true, force: true });
|
||||
if (built.status !== 0) {
|
||||
console.error("building reasonix-computer-helper.exe failed");
|
||||
process.exit(built.status ?? 1);
|
||||
}
|
||||
console.log("built reasonix-computer-helper.exe");
|
||||
}
|
||||
const driver = spawnSync(process.execPath, [path.join(__dirname, "install-cua-driver.js")], { stdio: "inherit", windowsHide: true });
|
||||
if (driver.status !== 0) process.exit(driver.status ?? 1);
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
// go build names the binary after the package and adds .exe where it belongs.
|
||||
@@ -109,7 +51,6 @@ if (process.platform !== "darwin") {
|
||||
const goarch = (process.env.REASONIX_GOARCH || "").trim();
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
|
||||
console.log("built reasonix-studio-host");
|
||||
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -131,29 +72,3 @@ for (const slice of slices) {
|
||||
fs.rmSync(slice, { force: true });
|
||||
}
|
||||
console.log("built reasonix-studio-host (universal)");
|
||||
|
||||
// The helper that operates other applications, universal for the same reason.
|
||||
// It targets macOS 12, the oldest this app runs on; what needs a later system
|
||||
// checks at run time and answers computer.unsupported.
|
||||
const HELPER = path.join(ROOT, "desktop", "computer-helper", "Sources");
|
||||
const sources = fs.readdirSync(HELPER).filter((f) => f.endsWith(".swift")).map((f) => path.join(HELPER, f));
|
||||
const helperSlices = [["x86_64", "amd64"], ["arm64", "arm64"]].map(([target, arch]) => {
|
||||
const out = path.join(OUT, `reasonix-computer-helper-${arch}`);
|
||||
const built = spawnSync("swiftc", ["-O", "-target", `${target}-apple-macos12`, ...sources, "-o", out], { stdio: "inherit" });
|
||||
if (built.status !== 0) {
|
||||
console.error(`swiftc for ${target} failed`);
|
||||
process.exit(built.status ?? 1);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const helper = path.join(OUT, "reasonix-computer-helper");
|
||||
fs.rmSync(helper, { force: true });
|
||||
const helperMerged = spawnSync("lipo", ["-create", "-output", helper, ...helperSlices], { stdio: "inherit" });
|
||||
if (helperMerged.status !== 0) {
|
||||
console.error("lipo failed for the computer-use helper");
|
||||
process.exit(helperMerged.status ?? 1);
|
||||
}
|
||||
for (const slice of helperSlices) {
|
||||
fs.rmSync(slice, { force: true });
|
||||
}
|
||||
console.log("built reasonix-computer-helper (universal)");
|
||||
@@ -6,7 +6,7 @@ const fs = require("node:fs");
|
||||
// an older system, so the Linux package refuses one.
|
||||
const PT_INTERP = 3;
|
||||
|
||||
function elfInterpreter(bytes) {
|
||||
function elfHeader(bytes) {
|
||||
if (bytes.length < 52 || bytes.readUInt32BE(0) !== 0x7f454c46) throw new Error("not an ELF file");
|
||||
const wide = bytes[4] === 2;
|
||||
const little = bytes[5] === 1;
|
||||
@@ -15,6 +15,27 @@ function elfInterpreter(bytes) {
|
||||
if (wide && bytes.length < 64) throw new Error("truncated ELF header");
|
||||
const u16 = (at) => (little ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at));
|
||||
const u32 = (at) => (little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at));
|
||||
return { wide, little, u16, u32 };
|
||||
}
|
||||
|
||||
function elfArchitecture(bytes) {
|
||||
const { wide, little, u16 } = elfHeader(bytes);
|
||||
if (!wide || !little) throw new Error("Linux packages require little-endian ELF64");
|
||||
const arch = { 62: "amd64", 183: "arm64" }[u16(18)];
|
||||
if (!arch) throw new Error(`unsupported ELF machine ${u16(18)}`);
|
||||
return arch;
|
||||
}
|
||||
|
||||
function assertArchitecture(files, arch) {
|
||||
if (!["amd64", "arm64"].includes(arch)) throw new Error(`unsupported release architecture ${arch}`);
|
||||
for (const file of files) {
|
||||
const actual = elfArchitecture(fs.readFileSync(file));
|
||||
if (actual !== arch) throw new Error(`${file} is ${actual}, expected ${arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
function elfInterpreter(bytes) {
|
||||
const { wide, little, u16, u32 } = elfHeader(bytes);
|
||||
const word = (at) => (wide ? Number(little ? bytes.readBigUInt64LE(at) : bytes.readBigUInt64BE(at)) : u32(at));
|
||||
const phoff = word(wide ? 32 : 28);
|
||||
const phentsize = u16(wide ? 54 : 42);
|
||||
@@ -40,7 +61,7 @@ function dynamicallyLinked(files) {
|
||||
return found;
|
||||
}
|
||||
|
||||
module.exports = { elfInterpreter, dynamicallyLinked };
|
||||
module.exports = { elfInterpreter, dynamicallyLinked, elfArchitecture, assertArchitecture };
|
||||
|
||||
if (require.main === module) {
|
||||
const files = process.argv.slice(2);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const lock = require("../../../internal/platform/computer/cua-driver.lock.json");
|
||||
|
||||
function artifact(platform, arch) {
|
||||
const key = platform === "darwin" ? "darwin-universal" : `${platform}-${arch}`;
|
||||
const selected = lock.artifacts[key];
|
||||
if (!selected) throw new Error(`No pinned CUA artifact for ${key}`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function locate(dir, name) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const candidate = path.join(dir, entry.name);
|
||||
if (entry.isFile() && entry.name === name) return candidate;
|
||||
if (entry.isDirectory()) { const found = await locate(candidate, name); if (found) return found; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function targetArchitecture(goarch, nativeArch) {
|
||||
if (!goarch) return nativeArch;
|
||||
if (goarch === "amd64") return "x64";
|
||||
if (goarch === "arm64") return "arm64";
|
||||
throw new Error(`Unsupported CUA target architecture ${goarch}`);
|
||||
}
|
||||
|
||||
async function install(platform = process.platform, arch = targetArchitecture(process.env.REASONIX_GOARCH, process.arch)) {
|
||||
const selected = artifact(platform, arch);
|
||||
const tempRoot = path.resolve(os.tmpdir());
|
||||
const stage = await fs.mkdtemp(path.join(tempRoot, "reasonix-cua-"));
|
||||
try {
|
||||
const url = `https://github.com/${lock.repository}/releases/download/${lock.tag}/${selected.name}`;
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
||||
if (!response.ok) throw new Error(`CUA download failed: HTTP ${response.status}`);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > 128 * 1024 * 1024) throw new Error("CUA artifact exceeds size limit");
|
||||
if (crypto.createHash("sha256").update(bytes).digest("hex") !== selected.sha256) throw new Error("CUA artifact SHA-256 mismatch");
|
||||
const archive = path.join(stage, selected.name);
|
||||
await fs.writeFile(archive, bytes);
|
||||
const extracted = path.join(stage, "extracted");
|
||||
await fs.mkdir(extracted);
|
||||
extract(archive, extracted);
|
||||
const name = platform === "win32" ? "cua-driver.exe" : "cua-driver";
|
||||
const source = await locate(extracted, name);
|
||||
if (!source) throw new Error("CUA artifact contains no driver executable");
|
||||
const bin = path.resolve(__dirname, "../bin");
|
||||
await fs.mkdir(bin, { recursive: true });
|
||||
const destination = path.join(bin, name);
|
||||
await fs.copyFile(source, destination);
|
||||
if (platform !== "win32") await fs.chmod(destination, 0o755);
|
||||
return destination;
|
||||
} finally {
|
||||
if (!path.resolve(stage).startsWith(tempRoot + path.sep)) throw new Error("Invalid CUA staging path");
|
||||
await fs.rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function extract(archive, extracted) {
|
||||
const cwd = path.dirname(archive);
|
||||
const tar = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:/Windows", "System32", "tar.exe") : "tar";
|
||||
const result = spawnSync(tar, ["-xf", path.basename(archive), "-C", path.relative(cwd, extracted)], { cwd, stdio: "inherit", windowsHide: true });
|
||||
if (result.status !== 0) throw new Error("CUA extraction failed");
|
||||
}
|
||||
|
||||
module.exports = { artifact, install, targetArchitecture, extract };
|
||||
if (require.main === module) install().then(file => process.stdout.write(`Installed pinned CUA Driver ${lock.version}: ${file}\n`)).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
||||
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { assertArchitecture, dynamicallyLinked } = require("./elf");
|
||||
const { name } = require("../package.json");
|
||||
|
||||
function filesIn(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const file = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? filesIn(file) : entry.isFile() ? [file] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function verifyLinuxTree(root, arch) {
|
||||
const files = filesIn(root);
|
||||
const hosts = files.filter((file) => file.endsWith(path.join("resources", "bin", "reasonix-studio-host")));
|
||||
if (hosts.length !== 1) throw new Error(`expected one packaged host, found ${hosts.length}`);
|
||||
const shell = path.resolve(path.dirname(hosts[0]), "../..", name);
|
||||
const helper = path.join(root, "usr/lib/reasonix-studio/reasonix-studio-update-helper");
|
||||
for (const file of [shell, hosts[0], helper]) {
|
||||
if (!fs.statSync(file).isFile()) throw new Error(`missing executable ${file}`);
|
||||
}
|
||||
const images = files.filter((file) => {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const magic = Buffer.alloc(4);
|
||||
return fs.readSync(fd, magic, 0, 4, 0) === 4 && magic.readUInt32BE(0) === 0x7f454c46;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
});
|
||||
for (const file of [shell, hosts[0], helper]) {
|
||||
if (!images.includes(file)) throw new Error(`${file} is not an ELF executable`);
|
||||
}
|
||||
assertArchitecture(images, arch);
|
||||
const dynamic = dynamicallyLinked([hosts[0], helper]);
|
||||
if (dynamic.length) throw new Error(`Go binaries must be static: ${dynamic.map((item) => item.file).join(", ")}`);
|
||||
return shell;
|
||||
}
|
||||
|
||||
module.exports = { verifyLinuxTree };
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv.length !== 4) throw new Error("usage: node packaging/linux-tree.js <extracted-package> <amd64|arm64>");
|
||||
console.log(verifyLinuxTree(path.resolve(process.argv[2]), process.argv[3]));
|
||||
}
|
||||
Generated
+18
-18
@@ -9,14 +9,14 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
specifier: 44.7.0
|
||||
version: 44.7.0([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
playwright:
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.5.1:
|
||||
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
|
||||
electron@44.7.0:
|
||||
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -762,13 +762,13 @@ packages:
|
||||
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.5.1([email protected]):
|
||||
electron@44.7.0([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
@@ -1696,7 +1696,7 @@ snapshots:
|
||||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
@@ -1883,11 +1883,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -1896,7 +1896,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
tar: 7.5.22
|
||||
tinyglobby: 0.2.17
|
||||
undici: 6.28.0
|
||||
@@ -1942,11 +1942,11 @@ snapshots:
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]3.0: {}
|
||||
[email protected]4.0: {}
|
||||
|
||||
[email protected]3.0:
|
||||
[email protected]4.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
playwright-core: 1.64.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
//
|
||||
// Nothing is answered. Each is performed by ending or by showing, so an
|
||||
// acknowledgement would have to come from a process on its way out.
|
||||
function actHandler({ app, showWindow }) {
|
||||
function actHandler({ app, showWindow, log = () => {} }) {
|
||||
return (act) => {
|
||||
if (act === "show" || act === "relaunch" || act === "quit") log(`kernel act: ${act}`);
|
||||
if (act === "relaunch") {
|
||||
app.relaunch();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
|
||||
const DOT_RADIUS = 4.5;
|
||||
const DOT_RING = 5.75;
|
||||
|
||||
function setUnreadBadge(unread, win, electron = require("electron"), platform = process.platform) {
|
||||
if (platform === "darwin") {
|
||||
electron.app.dock.setBadge(unread ? "•" : "");
|
||||
} else if (platform === "win32") {
|
||||
let image = null;
|
||||
if (unread) {
|
||||
const pixels = Buffer.alloc(16 * 16 * 4);
|
||||
for (let y = 0; y < 16; y++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const d = Math.hypot(x + 0.5 - 8, y + 0.5 - 8);
|
||||
// A red dot inside a thin white ring, edges smoothed by coverage, so it
|
||||
// reads on any taskbar colour and over any icon.
|
||||
const outer = Math.min(Math.max(DOT_RING + 0.5 - d, 0), 1);
|
||||
if (outer === 0) continue;
|
||||
const inner = Math.min(Math.max(DOT_RADIUS + 0.5 - d, 0), 1);
|
||||
// Native bitmaps are premultiplied BGRA, including on Windows ARM.
|
||||
const mix = (red) => Math.round((255 + (red - 255) * inner) * outer);
|
||||
pixels.set([mix(64), mix(64), mix(224), Math.round(255 * outer)], (y * 16 + x) * 4);
|
||||
}
|
||||
}
|
||||
image = electron.nativeImage.createFromBitmap(pixels, { width: 16, height: 16 });
|
||||
}
|
||||
win.setOverlayIcon(image, unread ? "Reasonix Studio" : "");
|
||||
} else {
|
||||
electron.app.setBadgeCount(unread ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setUnreadBadge };
|
||||
@@ -152,6 +152,10 @@ class BrowserViews {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
}
|
||||
this.shown = targetId;
|
||||
// A page that was parked comes back as a view that is attached but whose
|
||||
// widget never repaints until the window's geometry changes; attaching it
|
||||
// again is what wakes it, the same as after a minimize.
|
||||
if (!entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
// Pixels wider than this are scaled down before they leave the shell: a 4K
|
||||
// window as a PNG overruns the feedback upload limit.
|
||||
const MAX_WIDTH = 1920;
|
||||
|
||||
// The verb takes no argument and has no target of its own: the window is
|
||||
// whichever one the asking page lives in, and fromWindow refuses any other
|
||||
// sender. Nothing outside that window's own contents can be captured.
|
||||
function captureHandler(fromWindow) {
|
||||
return async (event) => {
|
||||
const target = fromWindow(event);
|
||||
if (!target) return null;
|
||||
let image = await target.webContents.capturePage();
|
||||
if (image.isEmpty()) return null;
|
||||
const { width } = image.getSize();
|
||||
if (width > MAX_WIDTH) image = image.resize({ width: MAX_WIDTH, quality: "good" });
|
||||
return new Uint8Array(image.toPNG());
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { captureHandler, MAX_WIDTH };
|
||||
@@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
function metadata(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const connection = net.createConnection(socketPath);
|
||||
let text = "";
|
||||
const finish = (error, value) => { connection.destroy(); error ? reject(error) : resolve(value); };
|
||||
connection.setTimeout(500, () => finish(new Error("CUA readiness timeout")));
|
||||
connection.on("error", error => finish(error));
|
||||
connection.on("connect", () => connection.write('{"method":"metadata"}\n'));
|
||||
connection.on("data", bytes => {
|
||||
text += bytes.toString("utf8");
|
||||
if (Buffer.byteLength(text) > 16384) return finish(new Error("CUA readiness response exceeds limit"));
|
||||
const end = text.indexOf("\n");
|
||||
if (end < 0) return;
|
||||
try { finish(null, JSON.parse(text.slice(0, end))); } catch (error) { finish(error); }
|
||||
});
|
||||
connection.on("end", () => finish(new Error("CUA readiness connection ended")));
|
||||
});
|
||||
}
|
||||
|
||||
function accepts(reply, pid, contract) {
|
||||
const value = reply?.result;
|
||||
return reply?.ok === true && value?.pid === pid && value.embedded === true && value.host_bundle_id === contract.host_bundle_id && value.driver_version === contract.version && value.contract_version === contract.contract_version && value.tools_list_schema_version === contract.tools_list_schema_version && value.capability_version === contract.capability_version && value.mcp_protocol_version === contract.protocol_version;
|
||||
}
|
||||
|
||||
async function startEmbedded(executable, contract, onStderr = () => {}, spawnDriver = spawn) {
|
||||
if (!path.isAbsolute(executable)) throw new Error("CUA executable must have an absolute path");
|
||||
const directory = await fs.mkdtemp("/tmp/reasonix-cua-");
|
||||
await fs.chmod(directory, 0o700);
|
||||
const socket = path.join(directory, "driver.sock");
|
||||
const abortSocket = path.join(directory, "abort.sock");
|
||||
const peers = new Set();
|
||||
let child = null;
|
||||
let identity = null;
|
||||
let stopping = null;
|
||||
let aborting = null;
|
||||
|
||||
async function unlinkOwned() {
|
||||
try {
|
||||
const current = await fs.lstat(socket);
|
||||
if (identity && current.dev === identity.dev && current.ino === identity.ino) await fs.unlink(socket);
|
||||
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
identity = null;
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
child = spawnDriver(executable, ["serve", "--embedded", "--parent-liveness-stdio", "--no-permissions-gate", "--socket", socket, "--host-bundle-id", contract.host_bundle_id, "--permission-mode", "standard"], {
|
||||
stdio: ["pipe", "ignore", "pipe"], windowsHide: true,
|
||||
env: { ...process.env, CUA_DRIVER_EMBEDDED_HOST_PID: String(process.pid), CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", DO_NOT_TRACK: "1" },
|
||||
});
|
||||
let failure = null;
|
||||
child.on("error", error => { failure = error; });
|
||||
child.stderr.on("data", bytes => onStderr(bytes.toString("utf8")));
|
||||
const deadline = Date.now() + 10000;
|
||||
while (Date.now() < deadline) {
|
||||
if (failure) throw failure;
|
||||
if (child.exitCode !== null || child.signalCode !== null) throw new Error("Embedded CUA exited during startup");
|
||||
try {
|
||||
const current = await fs.lstat(socket);
|
||||
if (!current.isSocket() || (current.mode & 0o777) !== 0o600) throw new Error("Invalid private CUA socket");
|
||||
const reply = await metadata(socket);
|
||||
if (!accepts(reply, child.pid, contract)) throw new Error("Embedded CUA identity/version mismatch");
|
||||
identity = current;
|
||||
return;
|
||||
} catch (error) { if (!["ENOENT", "ECONNREFUSED"].includes(error.code)) throw error; }
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Embedded CUA failed readiness");
|
||||
}
|
||||
|
||||
async function abort() {
|
||||
if (stopping) throw new Error("CUA is shutting down");
|
||||
await stopProcess(child, true);
|
||||
await unlinkOwned();
|
||||
if (stopping) throw new Error("CUA is shutting down");
|
||||
await launch();
|
||||
}
|
||||
|
||||
const control = net.createServer(peer => {
|
||||
peers.add(peer);
|
||||
peer.on("close", () => peers.delete(peer));
|
||||
peer.on("error", () => peer.destroy());
|
||||
peer.setTimeout(15000, () => peer.destroy());
|
||||
let text = "";
|
||||
peer.on("data", bytes => {
|
||||
text += bytes.toString("utf8");
|
||||
if (Buffer.byteLength(text) > 1024) return peer.destroy();
|
||||
const end = text.indexOf("\n");
|
||||
if (end < 0) return;
|
||||
peer.removeAllListeners("data");
|
||||
try {
|
||||
const request = JSON.parse(text.slice(0, end));
|
||||
if (request.op !== "abort" || Object.keys(request).length !== 1) throw new Error("Invalid CUA control operation");
|
||||
aborting ??= abort().finally(() => { aborting = null; });
|
||||
aborting.then(() => peer.end('{"ok":true}\n'), () => peer.end('{"ok":false}\n'));
|
||||
} catch { peer.end('{"ok":false}\n'); }
|
||||
});
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
for (const peer of peers) peer.destroy();
|
||||
if (control.listening) await new Promise(resolve => control.close(resolve));
|
||||
if (aborting) await aborting.catch(() => {});
|
||||
if (child) await stopProcess(child);
|
||||
await unlinkOwned();
|
||||
await fs.rmdir(directory).catch(error => { if (!["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error; });
|
||||
}
|
||||
const stop = () => { stopping ??= shutdown(); return stopping; };
|
||||
try {
|
||||
await launch();
|
||||
await new Promise((resolve, reject) => { control.once("error", reject); control.listen(abortSocket, resolve); });
|
||||
await fs.chmod(abortSocket, 0o600);
|
||||
return { socket, abortSocket, stop };
|
||||
} catch (error) { await stop(); throw error; }
|
||||
}
|
||||
|
||||
async function stopProcess(child, force = false) {
|
||||
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = new Promise(resolve => child.once("close", resolve));
|
||||
if (force) child.kill("SIGKILL");
|
||||
else {
|
||||
child.stdin.end();
|
||||
let timer;
|
||||
await Promise.race([exited, new Promise(resolve => { timer = setTimeout(resolve, 1500); })]);
|
||||
clearTimeout(timer);
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
await exited;
|
||||
}
|
||||
|
||||
function createQuitGate(current, quit, report) {
|
||||
let state = "idle";
|
||||
return event => {
|
||||
if (state === "finished") return;
|
||||
if (state === "draining") { event.preventDefault(); return; }
|
||||
const owned = current();
|
||||
if (!owned) return;
|
||||
event.preventDefault();
|
||||
state = "draining";
|
||||
Promise.resolve().then(() => owned.stop()).catch(report).finally(() => {
|
||||
state = "finished";
|
||||
quit();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { accepts, metadata, startEmbedded, stopProcess, createQuitGate };
|
||||
@@ -12,4 +12,11 @@ function groundFor(prefs, systemDark) {
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
module.exports = { GROUND, groundFor };
|
||||
// The scheme the window frame takes. Windows draws a frameless window's border
|
||||
// from it, so a dark page on a light system needs it set to stay borderless.
|
||||
function themeSourceFor(prefs) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
return theme === "dark" || theme === "light" ? theme : "system";
|
||||
}
|
||||
|
||||
module.exports = { GROUND, groundFor, themeSourceFor };
|
||||
@@ -15,13 +15,11 @@ function hostBinary({ packaged, resourcesPath, dirname, platform, env = {} }) {
|
||||
return path.join(packaged ? resourcesPath : path.join(dirname, ".."), "bin", name);
|
||||
}
|
||||
|
||||
// The helper that operates other applications exists on macOS and Windows. Its
|
||||
// permissions are the application's that started it, so it runs as this
|
||||
// shell's grandchild rather than being found anywhere else.
|
||||
function computerHelper({ packaged, resourcesPath, dirname, platform, env = {} }) {
|
||||
if (env.REASONIX_COMPUTER_HELPER) return env.REASONIX_COMPUTER_HELPER;
|
||||
if (platform !== "darwin" && platform !== "win32") return "";
|
||||
const name = platform === "win32" ? "reasonix-computer-helper.exe" : "reasonix-computer-helper";
|
||||
// The host owns the packaged CUA Driver; macOS permissions belong to its shell.
|
||||
function computerDriver({ packaged, resourcesPath, dirname, platform, env = {} }) {
|
||||
if (env.REASONIX_CUA_DRIVER) return env.REASONIX_CUA_DRIVER;
|
||||
if (!["darwin", "win32", "linux"].includes(platform)) return "";
|
||||
const name = platform === "win32" ? "cua-driver.exe" : "cua-driver";
|
||||
return path.join(packaged ? resourcesPath : path.join(dirname, ".."), "bin", name);
|
||||
}
|
||||
|
||||
@@ -31,4 +29,8 @@ function pageDir({ packaged, resourcesPath, dirname, env = {} }) {
|
||||
return path.join(root, "frontend-next", "dist");
|
||||
}
|
||||
|
||||
module.exports = { hostBinary, computerHelper, pageDir };
|
||||
function computerDriverContract({ packaged, resourcesPath, dirname }) {
|
||||
return packaged ? path.join(resourcesPath, "bin", "cua-driver.lock.json") : path.resolve(dirname, "../../../internal/platform/computer/cua-driver.lock.json");
|
||||
}
|
||||
|
||||
module.exports = { computerDriverContract, hostBinary, computerDriver, pageDir };
|
||||
@@ -8,6 +8,7 @@ const fs = require("node:fs/promises");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
|
||||
const { startEmbedded, createQuitGate } = require("./cua");
|
||||
const { showStarting } = require("./starting");
|
||||
const { StudioHost } = require("./hostclient");
|
||||
const { installTray } = require("./tray");
|
||||
@@ -17,20 +18,23 @@ const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal, revealWorkspace } = require("./reveal");
|
||||
const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const { setUnreadBadge } = require("./badge");
|
||||
const layout = require("./layout");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { captureHandler } = require("./capture");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { raise } = require("./raise");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
@@ -63,11 +67,12 @@ const where = {
|
||||
env: process.env,
|
||||
};
|
||||
const hostBinary = layout.hostBinary(where);
|
||||
const computerHelper = layout.computerHelper(where);
|
||||
const computerDriver = layout.computerDriver(where);
|
||||
const pageDir = layout.pageDir(where);
|
||||
|
||||
const acts = actHandler({ app, showWindow: () => showWindow() });
|
||||
const acts = actHandler({ app, showWindow: () => showWindow(), log: (line) => logs?.shell.line(line) });
|
||||
let kernel = null;
|
||||
let cua = null;
|
||||
let win = null;
|
||||
let origin = "";
|
||||
let quitting = false;
|
||||
@@ -156,7 +161,14 @@ async function boot() {
|
||||
// falls back to Electron's own, which named a Studio that never shipped and
|
||||
// ranked it ahead of every published release.
|
||||
const args = ["-page", pageDir, "-crash-dir", crashDir(logs.dir)];
|
||||
if (computerHelper && existsSync(computerHelper)) args.push("-computer-helper", computerHelper);
|
||||
if (computerDriver && existsSync(computerDriver)) {
|
||||
if (process.platform === "darwin") {
|
||||
const contract = JSON.parse(await fs.readFile(layout.computerDriverContract(where), "utf8"));
|
||||
cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text));
|
||||
args.push("-computer-socket", cua.socket, "-computer-abort-socket", cua.abortSocket);
|
||||
}
|
||||
args.push("-cua-driver", computerDriver);
|
||||
}
|
||||
if (app.isPackaged) {
|
||||
args.push("-studio-version", app.getVersion());
|
||||
// The other half the kernel cannot work out: which file the application
|
||||
@@ -183,6 +195,7 @@ async function boot() {
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
|
||||
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
|
||||
});
|
||||
followTheme();
|
||||
win = createWindow();
|
||||
closeStarting();
|
||||
guard(win.webContents);
|
||||
@@ -293,8 +306,7 @@ function showWindow() {
|
||||
if (quitting || !win || win.isDestroyed()) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
reload?.revive();
|
||||
win.show();
|
||||
win.focus();
|
||||
logs?.shell.line(`shell: window raised; focused=${raise(win)}`);
|
||||
}
|
||||
|
||||
// A window larger than the display it opens on puts its own title bar off
|
||||
@@ -307,7 +319,11 @@ function fitted() {
|
||||
};
|
||||
}
|
||||
|
||||
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
|
||||
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
|
||||
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
|
||||
const followTheme = () => {
|
||||
nativeTheme.themeSource = themeSourceFor(storedPrefs());
|
||||
};
|
||||
|
||||
function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
@@ -366,6 +382,7 @@ function fromWindow(event) {
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
followTheme();
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
});
|
||||
@@ -375,6 +392,11 @@ nativeTheme.on("updated", () => {
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
|
||||
ipcMain.handle("window:unread-badge", (event, unread) => {
|
||||
const target = fromWindow(event);
|
||||
if (target && typeof unread === "boolean") setUnreadBadge(unread, target);
|
||||
});
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
});
|
||||
@@ -385,6 +407,7 @@ ipcMain.handle("window:toggle-maximise", (event) => {
|
||||
else target.maximize();
|
||||
});
|
||||
ipcMain.handle("window:is-maximised", (event) => fromWindow(event)?.isMaximized() ?? false);
|
||||
ipcMain.handle("window:capture", (event) => captureHandler(fromWindow)(event));
|
||||
ipcMain.handle("window:close", (event) => {
|
||||
fromWindow(event)?.close();
|
||||
});
|
||||
@@ -415,9 +438,18 @@ ipcMain.handle("shell:open-external", (event, raw) => {
|
||||
ipcMain.handle("shell:reveal-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal-logs", (event) =>
|
||||
fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:open-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? openWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:open-file", (event, base, rel) =>
|
||||
fromWindow(event) && client ? openFile(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
|
||||
// A dismissed dialog answers with "", which is what the page reads as "they
|
||||
// said no". Only a failure to write is an error.
|
||||
@@ -446,7 +478,8 @@ ipcMain.handle("dialog:pick-folder", async (event, startIn) => {
|
||||
if (!target) return "";
|
||||
const picked = await dialog.showOpenDialog(target, {
|
||||
defaultPath: startIn || undefined,
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
// Tools keep their working folders behind a leading dot, so those are listed too.
|
||||
properties: ["openDirectory", "createDirectory", "showHiddenFiles"],
|
||||
});
|
||||
if (picked.canceled || !picked.filePaths.length) return "";
|
||||
return picked.filePaths[0];
|
||||
@@ -538,9 +571,11 @@ app.on("window-all-closed", () => {
|
||||
// than a copy of it.
|
||||
module.exports = { current: () => ({ win, tray, client, origin }) };
|
||||
|
||||
app.on("before-quit", () => {
|
||||
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
|
||||
app.on("before-quit", event => {
|
||||
quitting = true;
|
||||
logs.shell.line("shell: quitting");
|
||||
drainCua(event);
|
||||
browserRelay?.stop();
|
||||
powerGuard?.close();
|
||||
tray?.close();
|
||||
|
||||
@@ -21,17 +21,24 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
shell: "electron",
|
||||
platform: process.platform,
|
||||
titleBar,
|
||||
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
|
||||
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
|
||||
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
|
||||
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
|
||||
closeWindow: () => ipcRenderer.invoke("window:close"),
|
||||
// A PNG of this window's own page, taking nothing: there is no way to name another.
|
||||
captureWindow: () => ipcRenderer.invoke("window:capture"),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
openFile: (base, rel) => ipcRenderer.invoke("shell:open-file", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
|
||||
// The same project, entered rather than selected in its parent folder.
|
||||
openWorkspace: (root) => ipcRenderer.invoke("shell:open-workspace", String(root)),
|
||||
// Where a dropped file lives. Resolved here rather than in the page: the
|
||||
// renderer is handed a File and never a path, and a turn that has to work on
|
||||
// the file itself cannot do it on a copy of the bytes.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
// Windows refuses a background process the foreground, and a focus() it refused
|
||||
// only flashes the taskbar button. A window that is topmost for one call is
|
||||
// raised past that rule, and handed back to the normal stacking order at once.
|
||||
// Answers whether the window ended up focused, so the caller can log what the
|
||||
// platform actually allowed.
|
||||
function raise(win, platform = process.platform) {
|
||||
win.show();
|
||||
win.focus();
|
||||
if (platform !== "win32" || win.isFocused()) return win.isFocused();
|
||||
win.setAlwaysOnTop(true);
|
||||
win.focus();
|
||||
win.setAlwaysOnTop(false);
|
||||
return win.isFocused();
|
||||
}
|
||||
|
||||
module.exports = { raise };
|
||||
@@ -1,9 +1,16 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// A pane's base as the hub publishes it. Nothing else may steer a request this
|
||||
// process sends with the credential, and the unprefixed root is no pane.
|
||||
const PANE_BASE = /^\/rt\/[A-Za-z0-9_-]+$/;
|
||||
const DOCUMENT_MEDIA = new Set([
|
||||
".pdf", ".txt", ".md", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".rtf",
|
||||
".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".avif", ".heic",
|
||||
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".mp4", ".mov", ".webm", ".mkv",
|
||||
]);
|
||||
|
||||
function refused(error, code = "") {
|
||||
return { code, error };
|
||||
@@ -67,4 +74,60 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace };
|
||||
// The shell's own logs folder. The shell names it, never the page, and it is
|
||||
// opened rather than selected: it is a directory this app created. null means
|
||||
// it was shown.
|
||||
async function revealLogs(shell, dir) {
|
||||
if (!dir) return { error: "the logs folder is unknown" };
|
||||
const failed = await shell.openPath(String(dir));
|
||||
return failed ? { error: failed } : null;
|
||||
}
|
||||
|
||||
// A folder openPath may enter: a directory, and on macOS not an application
|
||||
// bundle, which openPath would launch instead of showing.
|
||||
async function isPlainFolder(target, platform, fsApi) {
|
||||
let stat;
|
||||
try {
|
||||
stat = await fsApi.stat(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!stat.isDirectory()) return false;
|
||||
if (platform !== "darwin") return true;
|
||||
try {
|
||||
await fsApi.access(path.join(target, "Contents", "Info.plist"));
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// openWorkspace enters a folder the sidebar lists instead of selecting it in
|
||||
// its parent. The kernel names the folder; anything that is not a plain folder
|
||||
// is only selected, as revealWorkspace does.
|
||||
async function openWorkspace(client, shell, root, platform = process.platform, fsApi = fs.promises) {
|
||||
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
|
||||
if (found.refusal) return found.refusal;
|
||||
if (!(await isPlainFolder(found.target, platform, fsApi))) {
|
||||
shell.showItemInFolder(found.target);
|
||||
return null;
|
||||
}
|
||||
const why = await shell.openPath(found.target);
|
||||
return why ? refused(why) : null;
|
||||
}
|
||||
|
||||
async function openFile(client, shell, base, rel, platform = process.platform, fsApi = fs.promises) {
|
||||
const found = await locate(client, String(base), String(rel), platform);
|
||||
if (found.refusal) return found.refusal;
|
||||
try {
|
||||
if (!(await fsApi.stat(found.target)).isFile()) return refused("not a regular file");
|
||||
const ext = (platform === "win32" ? path.win32 : path.posix).extname(found.target).toLowerCase();
|
||||
if (!DOCUMENT_MEDIA.has(ext)) return refused("only documents and media can be opened");
|
||||
const why = await shell.openPath(found.target);
|
||||
return why ? refused(why) : null;
|
||||
} catch (err) {
|
||||
return refused(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace, openFile };
|
||||
@@ -26,7 +26,7 @@ function buildTarget(dir) {
|
||||
<key>CFBundleName</key><string>Computer Target</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
</dict></plist>`);
|
||||
const source = path.join(__dirname, "..", "..", "..", "internal", "computer", "testdata", "target.swift");
|
||||
const source = path.join(__dirname, "..", "..", "..", "internal", "platform", "computer", "testdata", "target.swift");
|
||||
const built = spawnSync("swiftc", ["-O", source, "-o", path.join(macos, "target")], { stdio: "inherit" });
|
||||
if (built.status !== 0) throw new Error("the probe application did not build");
|
||||
const log = path.join(dir, "target.log");
|
||||
@@ -39,7 +39,7 @@ const logged = (log) => (fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "")
|
||||
function operatingModel() {
|
||||
return scriptedModel((tools) => {
|
||||
const last = tools.length ? tools[tools.length - 1] : "";
|
||||
const ref = (role, name) => (last.match(new RegExp(`- ${role} "${name}" \\[(a\\d+)\\]`)) || [])[1];
|
||||
const ref = (role, name) => (last.match(new RegExp(`- ${role} "${name}" \\[(r[A-Za-z0-9]+)\\]`)) || [])[1];
|
||||
switch (tools.length) {
|
||||
case 0:
|
||||
return { name: "computer_read", arguments: { what: "apps" } };
|
||||
@@ -47,10 +47,7 @@ function operatingModel() {
|
||||
return { name: "computer_read", arguments: { what: "snapshot", app: BUNDLE } };
|
||||
case 2:
|
||||
return { name: "computer_act", arguments: { app: BUNDLE, steps: [
|
||||
{ action: "set_value", ref: ref("textField", "Probe field"), text: "from-studio" },
|
||||
{ action: "click", ref: ref("button", "Probe button") },
|
||||
{ action: "focus", ref: ref("textField", "Probe field") },
|
||||
{ action: "type", text: " 李雷" },
|
||||
{ action: "set_value", ref: ref("textField", "Probe field"), text: "from-studio 李雷" },
|
||||
] } };
|
||||
case 3:
|
||||
return { name: "computer_read", arguments: { what: "screenshot", app: BUNDLE } };
|
||||
@@ -126,11 +123,10 @@ async function main() {
|
||||
const results = (requests[5].messages || []).filter((m) => m.role === "tool").map((m) => String(m.content || ""));
|
||||
check("the kernel was given computer use", results.length === 5, results.length);
|
||||
check("the model saw the probe among the applications", (results[0] || "").includes(`${BUNDLE} — `), (results[0] || "").slice(0, 400));
|
||||
check("the snapshot carried refs", /textField "Probe field" \[a\d+\]/.test(results[1] || ""), (results[1] || "").slice(0, 400));
|
||||
check("every step ran", (results[2] || "").includes("Completed 4 of 4 step(s)."), (results[2] || "").slice(0, 600));
|
||||
check("the snapshot carried refs", /textField "Probe field" \[r[A-Za-z0-9]+\]/.test(results[1] || ""), (results[1] || "").slice(0, 400));
|
||||
check("the value change ran", (results[2] || "").includes("Completed 1 of 1 step(s)."), (results[2] || "").slice(0, 600));
|
||||
await until("the input in the application", async () => logged(target.log).includes("text from-studio 李雷"), 10000).catch(() => {});
|
||||
const received = logged(target.log);
|
||||
check("the button was pressed", received.includes("button pressed"), received);
|
||||
check("the text arrived", received.includes("text from-studio 李雷"), received);
|
||||
check("the pointer is asked for although the application is already granted", person.asked.length === 3, person.asked);
|
||||
check("a refused pointer never reached the application", !received.includes("mouseDown"), received);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const contract = require("../../../internal/platform/computer/cua-driver.lock.json");
|
||||
const { install } = require("../packaging/install-cua-driver");
|
||||
const { startEmbedded, metadata } = require("../src/cua");
|
||||
const executable = await install();
|
||||
let owned;
|
||||
try {
|
||||
const env = { ...process.env, REASONIX_LIVE_COMPUTER: executable };
|
||||
if (process.platform === "darwin") {
|
||||
owned = await startEmbedded(executable, contract, text => process.stderr.write(text));
|
||||
const before = await metadata(owned.socket);
|
||||
assert.equal(before.result.embedded, true);
|
||||
env.REASONIX_LIVE_COMPUTER_SOCKET = owned.socket;
|
||||
env.REASONIX_LIVE_COMPUTER_ABORT_SOCKET = owned.abortSocket;
|
||||
}
|
||||
const child = spawn("go", ["test", "./internal/platform/computer", "-run", "^TestLive", "-count=1", "-v", "-timeout=3m"], { cwd: root, env, stdio: "inherit", windowsHide: true });
|
||||
const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); });
|
||||
if (code !== 0) process.exitCode = code ?? 1;
|
||||
} finally { await owned?.stop(); }
|
||||
@@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
const net = require("node:net");
|
||||
const args = process.argv.slice(2);
|
||||
const socket = args[args.indexOf("--socket") + 1];
|
||||
// The socket must exist at 0600 from the first instant: a reader that looks between listen and a later chmod sees 0755.
|
||||
process.umask(0o177);
|
||||
const server = net.createServer(peer => {
|
||||
peer.on("data", () => peer.end(JSON.stringify({ ok: true, result: { pid: process.pid, embedded: true, host_bundle_id: "io.reasonix.studio", driver_version: "0.34.1", contract_version: "0.8.0", tools_list_schema_version: "1", capability_version: "1", mcp_protocol_version: "2025-06-18" } }) + "\n"));
|
||||
});
|
||||
server.listen(socket);
|
||||
process.stdin.resume();
|
||||
process.stdin.on("end", () => server.close(() => process.exit(0)));
|
||||
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const { _electron } = require("playwright");
|
||||
|
||||
const [executablePath, arch, version] = process.argv.slice(2);
|
||||
assert.ok(executablePath && ["amd64", "arm64"].includes(arch) && version, "usage: linux-package-smoke.js EXECUTABLE ARCH VERSION");
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "studio-installed-"));
|
||||
const env = { ...process.env, REASONIX_HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache") };
|
||||
for (const key of ["REASONIX_STUDIO_HOST", "REASONIX_STUDIO_PAGE", "REASONIX_COMPUTER_HELPER", "REASONIX_DEV", "REASONIX_STATE_HOME", "ELECTRON_RUN_AS_NODE"]) delete env[key];
|
||||
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "SMOKE_API_KEY"\n');
|
||||
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=fixture\n");
|
||||
|
||||
async function until(check, message, timeout = 60000) {
|
||||
const end = Date.now() + timeout;
|
||||
while (Date.now() < end) {
|
||||
if (await check()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
try { process.kill(pid, 0); return true; } catch (error) {
|
||||
if (error.code === "ESRCH") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let application;
|
||||
let diagnostics = "";
|
||||
try {
|
||||
application = await _electron.launch({ executablePath, env, chromiumSandbox: true, timeout: 60000 });
|
||||
application.process().stderr?.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-16384); });
|
||||
const identity = await application.evaluate(({ app }) => ({ packaged: app.isPackaged, arch: process.arch, version: app.getVersion() }));
|
||||
assert.deepEqual(identity, { packaged: true, arch: arch === "amd64" ? "x64" : arch, version });
|
||||
let page;
|
||||
await until(async () => {
|
||||
page = application.windows().find((win) => win.url().startsWith("http://127.0.0.1:"));
|
||||
return Boolean(page);
|
||||
}, "the installed shell never loaded its bundled kernel's page");
|
||||
await page.waitForFunction(() => document.getElementById("root")?.children.length > 0 && document.documentElement.dataset.shell === "electron");
|
||||
const response = await page.evaluate(async () => {
|
||||
const result = await fetch("/status", { credentials: "same-origin" });
|
||||
return { status: result.status, body: await result.json() };
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(typeof response.body, "object");
|
||||
const shell = application.process();
|
||||
const children = execFileSync("ps", ["-o", "pid=", "--ppid", String(shell.pid)], { encoding: "utf8" }).trim().split(/\s+/).map(Number);
|
||||
const hostPath = path.join(path.dirname(executablePath), "resources/bin/reasonix-studio-host");
|
||||
const hosts = children.filter((pid) => {
|
||||
try { return fs.readlinkSync(`/proc/${pid}/exe`) === hostPath; } catch { return false; }
|
||||
});
|
||||
assert.equal(hosts.length, 1, "the installed shell must own exactly one bundled host");
|
||||
const second = spawn(executablePath, [], { env, stdio: "ignore" });
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { second.kill(); reject(new Error("second launch never yielded to the installed instance")); }, 30000);
|
||||
second.once("error", (error) => { clearTimeout(timer); reject(error); });
|
||||
second.once("exit", (code, signal) => { clearTimeout(timer); resolve({ code, signal }); });
|
||||
});
|
||||
assert.deepEqual(result, { code: 0, signal: null });
|
||||
assert.equal(alive(shell.pid), true);
|
||||
await application.close();
|
||||
application = null;
|
||||
assert.equal(shell.exitCode, 0, "the installed shell must exit normally");
|
||||
await until(() => hosts.every((pid) => !alive(pid)), "the bundled kernel outlived the installed shell", 10000);
|
||||
console.log(`installed ${arch} ${version}: packaged shell, renderer RPC, second launch and clean shutdown passed`);
|
||||
} catch (error) {
|
||||
console.error(diagnostics);
|
||||
throw error;
|
||||
} finally {
|
||||
if (application) await application.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { test } from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { elfArchitecture, elfInterpreter } = require("../packaging/elf.js");
|
||||
const { verifyLinuxTree } = require("../packaging/linux-tree.js");
|
||||
const { name } = require("../package.json");
|
||||
|
||||
function elf(arch, dynamic = false) {
|
||||
const bytes = Buffer.alloc(160);
|
||||
bytes.writeUInt32BE(0x7f454c46);
|
||||
bytes[4] = 2;
|
||||
bytes[5] = 1;
|
||||
bytes[6] = 1;
|
||||
bytes.writeUInt16LE(arch === "arm64" ? 183 : 62, 18);
|
||||
if (dynamic) {
|
||||
bytes.writeBigUInt64LE(64n, 32);
|
||||
bytes.writeUInt16LE(56, 54);
|
||||
bytes.writeUInt16LE(1, 56);
|
||||
bytes.writeUInt32LE(3, 64);
|
||||
bytes.writeBigUInt64LE(120n, 72);
|
||||
bytes.writeBigUInt64LE(12n, 96);
|
||||
bytes.write("/lib/ld.so\0", 120);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function fixture(t, arch) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "studio-linux-tree-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const app = path.join(root, "opt", "Reasonix Studio");
|
||||
const files = {
|
||||
shell: path.join(app, name),
|
||||
host: path.join(app, "resources/bin/reasonix-studio-host"),
|
||||
helper: path.join(root, "usr/lib/reasonix-studio/reasonix-studio-update-helper"),
|
||||
library: path.join(app, "libEGL.so"),
|
||||
};
|
||||
for (const file of Object.values(files)) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, elf(arch));
|
||||
}
|
||||
return { root, ...files };
|
||||
}
|
||||
|
||||
for (const arch of ["amd64", "arm64"]) {
|
||||
test(`the ${arch} shell, host, helper and libraries agree on the package architecture`, (t) => {
|
||||
const tree = fixture(t, arch);
|
||||
assert.equal(verifyLinuxTree(tree.root, arch), tree.shell);
|
||||
assert.equal(elfArchitecture(elf(arch)), arch);
|
||||
assert.equal(elfInterpreter(elf(arch)), null);
|
||||
});
|
||||
|
||||
for (const member of ["shell", "host", "helper", "library"]) {
|
||||
test(`a ${arch} package refuses a ${member} from the other architecture`, (t) => {
|
||||
const tree = fixture(t, arch);
|
||||
fs.writeFileSync(tree[member], elf(arch === "arm64" ? "amd64" : "arm64"));
|
||||
assert.throws(() => verifyLinuxTree(tree.root, arch), /expected/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of ["host", "helper"]) {
|
||||
test(`a ${arch} package refuses a dynamically linked ${member}`, (t) => {
|
||||
const tree = fixture(t, arch);
|
||||
fs.writeFileSync(tree[member], elf(arch, true));
|
||||
assert.throws(() => verifyLinuxTree(tree.root, arch), /must be static/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("missing or non-ELF required executables cannot pass the package check", (t) => {
|
||||
const tree = fixture(t, "arm64");
|
||||
fs.writeFileSync(tree.helper, "#!/bin/sh\n");
|
||||
assert.throws(() => verifyLinuxTree(tree.root, "arm64"), /not an ELF/);
|
||||
fs.unlinkSync(tree.host);
|
||||
assert.throws(() => verifyLinuxTree(tree.root, "arm64"), /expected one packaged host/);
|
||||
});
|
||||
|
||||
test("unsupported ELF encodings and release architectures fail closed", (t) => {
|
||||
const bytes = elf("arm64");
|
||||
bytes[5] = 2;
|
||||
assert.throws(() => elfArchitecture(bytes), /little-endian/);
|
||||
const tree = fixture(t, "arm64");
|
||||
assert.throws(() => verifyLinuxTree(tree.root, "aarch64"), /unsupported release architecture/);
|
||||
});
|
||||
|
||||
test("ARM64 RPM naming is normalized before the manifest consumes it", async (t) => {
|
||||
const { record, built } = require("../packaging/arch.js");
|
||||
const hook = require("../packaging/canonical-artifact-names.js");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-arm64-name-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const deb = path.join(dir, "ReasonixStudio-linux-arm64.deb");
|
||||
const rpm = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
|
||||
for (const file of [deb, rpm]) {
|
||||
fs.writeFileSync(file, "fixture");
|
||||
record(file, "arm64");
|
||||
t.after(() => built.delete(path.resolve(file)));
|
||||
}
|
||||
const files = await hook({ artifactPaths: [deb, rpm] });
|
||||
assert.deepEqual(files.map((file) => path.basename(file)), ["ReasonixStudio-linux-arm64.deb", "ReasonixStudio-linux-arm64.rpm"]);
|
||||
assert.ok(fs.existsSync(files[1]));
|
||||
});
|
||||
@@ -84,7 +84,7 @@ async function run() {
|
||||
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
|
||||
}))()`);
|
||||
|
||||
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
|
||||
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
|
||||
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
|
||||
|
||||
+404
-12
@@ -6,13 +6,14 @@ import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cuaContract = require("../../../internal/platform/computer/cua-driver.lock.json");
|
||||
const { parse, readActs, start } = require("../src/host.js");
|
||||
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
|
||||
const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
const { groundFor, GROUND } = require("../src/ground.js");
|
||||
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
@@ -205,7 +206,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
|
||||
assert.equal(await dead.trayState(), null);
|
||||
});
|
||||
|
||||
const { reveal, revealWorkspace } = require("../src/reveal.js");
|
||||
const { reveal, revealWorkspace, revealLogs, openWorkspace } = require("../src/reveal.js");
|
||||
|
||||
// A kernel that answers /workspace/locate as the test says, and a shell that
|
||||
// only records what it was asked to open.
|
||||
@@ -224,11 +225,121 @@ async function revealRig(answer, platform = process.platform) {
|
||||
openPath: async (p) => (opened.push(["open", p]), ""),
|
||||
showItemInFolder: (p) => opened.push(["select", p]),
|
||||
};
|
||||
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
|
||||
return {
|
||||
asked, opened, close: () => server.close(),
|
||||
run: (base, rel) => reveal(client, shell, base, rel, platform),
|
||||
workspace: (root) => revealWorkspace(client, shell, root, platform),
|
||||
open: (root, fsApi) => openWorkspace(client, shell, root, platform, fsApi),
|
||||
};
|
||||
}
|
||||
|
||||
// The sidebar's project entered rather than selected in its parent: the same
|
||||
// kernel answer revealWorkspace asks for, through the folder-opening verb.
|
||||
const folderFs = (over = {}) => ({
|
||||
stat: async () => ({ isDirectory: () => true }),
|
||||
access: async () => {
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
test("openWorkspace enters the folder the kernel named", async (t) => {
|
||||
for (const [platform, target] of [[process.platform, ROOT], ["darwin", "/Users/studio/workspace"], ["linux", "/home/studio/workspace"], ["win32", "C:\\Studio\\workspace"]]) {
|
||||
await t.test(`${platform}: ${target}`, async () => {
|
||||
const rig = await revealRig(() => [200, { path: target, dir: true }], platform);
|
||||
try {
|
||||
assert.equal(await rig.open("/w", folderFs()), null);
|
||||
assert.deepEqual(rig.opened, [["open", target]]);
|
||||
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fw");
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// openPath launches an application bundle instead of showing it, so a root
|
||||
// that is one is only selected, and so is anything that is not a directory.
|
||||
test("openWorkspace only selects an application bundle", async () => {
|
||||
const bundle = "/Applications/Workspace.app";
|
||||
const rig = await revealRig(() => [200, { path: bundle, dir: true }], "darwin");
|
||||
try {
|
||||
assert.equal(await rig.open("/w", folderFs({ access: async () => {} })), null);
|
||||
assert.deepEqual(rig.opened, [["select", bundle]]);
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("openWorkspace only selects what is not a directory or cannot be read", async () => {
|
||||
for (const fsApi of [folderFs({ stat: async () => ({ isDirectory: () => false }) }), folderFs({ stat: async () => { throw new Error("EACCES"); } })]) {
|
||||
const rig = await revealRig(() => [200, { path: ROOT, dir: true }]);
|
||||
try {
|
||||
assert.equal(await rig.open("/w", fsApi), null);
|
||||
assert.deepEqual(rig.opened, [["select", ROOT]]);
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("openWorkspace refuses a folder the hub does not list", async () => {
|
||||
const rig = await revealRig(() => [404, { code: "host.unknown_workspace", error: "not listed" }]);
|
||||
try {
|
||||
const why = await rig.open("/nope");
|
||||
assert.equal(why.code, "host.unknown_workspace");
|
||||
assert.deepEqual(rig.opened, []);
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
});
|
||||
|
||||
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
|
||||
|
||||
test("file opening refuses scripts, executables and unknown types on every platform", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => { opened.push(target); return ""; } };
|
||||
for (const platform of ["darwin", "win32", "linux"]) {
|
||||
for (const name of ["task.command", "task.sh", "task.bat", "task.cmd", "task.ps1", "task.lnk", "task.jar", "task.desktop", "task.scpt", "task.exe", "task.EXE", "report.pdf.exe", "task", "task.unknown", "page.html", "image.svg"]) {
|
||||
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
|
||||
const why = await openFile(client, shell, "/rt/r1", "report.pdf", platform, { stat: async () => ({ isFile: () => true }) });
|
||||
assert.ok(why?.error, `${platform}: ${name} must be refused`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(opened, []);
|
||||
});
|
||||
|
||||
test("file opening accepts kernel-located documents and media on every platform", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
for (const platform of ["darwin", "win32", "linux"]) {
|
||||
for (const name of ["report.pdf", "notes.TXT", "report.docx", "table.xlsx", "slides.pptx", "data.csv", "notes.md", "image.PNG", "photo.jpg", "video.mp4", "audio.mp3"]) {
|
||||
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
|
||||
const opened = [];
|
||||
const shell = { openPath: async (p) => { opened.push(p); return ""; } };
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
|
||||
assert.equal(await openFile(client, shell, "/rt/r1", name, platform, { stat: async () => ({ isFile: () => true }) }), null);
|
||||
assert.deepEqual(opened, [target]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("file opening uses only a kernel-located regular file and reports shell failures", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => { opened.push(target); return "no application"; } };
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: path.join(ROOT, "report.pdf") }) }) };
|
||||
assert.deepEqual(await openFile(client, shell, "/rt/r1", "report.pdf", process.platform, { stat: async () => ({ isFile: () => true }) }), { error: "no application", code: "" });
|
||||
assert.deepEqual(opened, [path.join(ROOT, "report.pdf")]);
|
||||
opened.length = 0;
|
||||
assert.ok(await openFile(client, shell, "/rt/r1", "folder", process.platform, { stat: async () => ({ isFile: () => false }) }));
|
||||
assert.ok(await openFile(client, shell, "https://attacker.test", "report.pdf"));
|
||||
const refused = { request: async () => ({ status: 400, body: JSON.stringify({ code: "workspace.path_outside_tree", error: "outside" }) }) };
|
||||
assert.equal((await openFile(refused, shell, "/rt/r1", "../secret.pdf")).code, "workspace.path_outside_tree");
|
||||
assert.deepEqual(opened, []);
|
||||
});
|
||||
|
||||
test("reveal opens only what the kernel located inside the pane's workspace", async () => {
|
||||
const rig = await revealRig((url) => {
|
||||
const rel = url.searchParams.get("path") ?? "";
|
||||
@@ -324,7 +435,7 @@ test("on Windows a share or device path from the kernel is refused", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
const { hostBinary, computerHelper, pageDir } = require("../src/layout.js");
|
||||
const { hostBinary, computerDriver, pageDir } = require("../src/layout.js");
|
||||
|
||||
// Packaged, both live in resources/ beside app.asar. Reading them from inside
|
||||
// it is the failure this pins: a child process cannot be spawned out of an
|
||||
@@ -342,13 +453,13 @@ test("the kernel and the page are found in both layouts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("the computer-use helper is found beside the kernel on macOS and Windows, and nowhere else", () => {
|
||||
test("the CUA Driver is found beside the kernel on all supported platforms", () => {
|
||||
const mac = { packaged: true, resourcesPath: "/res", dirname: "/d/src", platform: "darwin" };
|
||||
assert.equal(computerHelper(mac), path.join("/res", "bin", "reasonix-computer-helper"));
|
||||
assert.equal(computerHelper({ ...mac, packaged: false, dirname: path.join("/repo", "electron", "src") }), path.join("/repo", "electron", "bin", "reasonix-computer-helper"));
|
||||
assert.equal(computerHelper({ ...mac, platform: "win32" }), path.join("/res", "bin", "reasonix-computer-helper.exe"));
|
||||
assert.equal(computerHelper({ ...mac, platform: "linux" }), "");
|
||||
assert.equal(computerHelper({ ...mac, platform: "linux", env: { REASONIX_COMPUTER_HELPER: "/custom/helper" } }), "/custom/helper");
|
||||
assert.equal(computerDriver(mac), path.join("/res", "bin", "cua-driver"));
|
||||
assert.equal(computerDriver({ ...mac, packaged: false, dirname: path.join("/repo", "electron", "src") }), path.join("/repo", "electron", "bin", "cua-driver"));
|
||||
assert.equal(computerDriver({ ...mac, platform: "win32" }), path.join("/res", "bin", "cua-driver.exe"));
|
||||
assert.equal(computerDriver({ ...mac, platform: "linux" }), path.join("/res", "bin", "cua-driver"));
|
||||
assert.equal(computerDriver({ ...mac, platform: "linux", env: { REASONIX_CUA_DRIVER: "/custom/cua-driver" } }), "/custom/cua-driver");
|
||||
});
|
||||
|
||||
test("Windows gets the suffix spawn needs, and an override wins over both", () => {
|
||||
@@ -908,7 +1019,7 @@ test("only the Studio window reads or writes the preferences", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function elf64(interpreter) {
|
||||
function elf64(interpreter, machine = 62) {
|
||||
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
|
||||
const dataOffset = phoff + phnum * phentsize;
|
||||
const path = Buffer.from(interpreter ? `${interpreter}\0` : "", "latin1");
|
||||
@@ -918,7 +1029,7 @@ function elf64(interpreter) {
|
||||
bytes[5] = 1;
|
||||
bytes[6] = 1;
|
||||
bytes.writeUInt16LE(2, 16);
|
||||
bytes.writeUInt16LE(62, 18);
|
||||
bytes.writeUInt16LE(machine, 18);
|
||||
bytes.writeBigUInt64LE(BigInt(phoff), 32);
|
||||
bytes.writeUInt16LE(64, 52);
|
||||
bytes.writeUInt16LE(phentsize, 54);
|
||||
@@ -954,6 +1065,26 @@ test("a Linux Go binary that needs the build host's dynamic loader is refused",
|
||||
}
|
||||
});
|
||||
|
||||
test("a Linux package's Go binaries must be built for the architecture the package is named for", () => {
|
||||
const { elfArchitecture, assertArchitecture } = require("../packaging/elf.js");
|
||||
assert.equal(elfArchitecture(elf64(null, 62)), "amd64");
|
||||
assert.equal(elfArchitecture(elf64(null, 183)), "arm64");
|
||||
assert.throws(() => elfArchitecture(elf64(null, 40)), /unsupported ELF machine/);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-elf-"));
|
||||
try {
|
||||
const host = path.join(dir, "reasonix-studio-host");
|
||||
const helper = path.join(dir, "reasonix-studio-update-helper");
|
||||
fs.writeFileSync(host, elf64(null, 62));
|
||||
fs.writeFileSync(helper, elf64(null, 183));
|
||||
assert.throws(() => assertArchitecture([host, helper], "arm64"), /is amd64, expected arm64/);
|
||||
assert.throws(() => assertArchitecture([host, helper], "amd64"), /is arm64, expected amd64/);
|
||||
assert.doesNotThrow(() => assertArchitecture([host], "amd64"));
|
||||
assert.throws(() => assertArchitecture([host], "riscv64"), /unsupported release architecture/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a Wayland session with XWayland relaunches the shell on X11 before anything else starts", () => {
|
||||
const { relaunchForOzonePlatform } = require("../src/ozone.js");
|
||||
const X11 = "--ozone-platform=x11";
|
||||
@@ -1722,6 +1853,16 @@ test("the window ground follows the stored theme and, for auto, the system", ()
|
||||
assert.equal(groundFor(undefined, true), GROUND.dark);
|
||||
});
|
||||
|
||||
// Windows draws a frameless window's border from the frame's scheme, so a dark
|
||||
// page on a light system must set it, or the border is a light line.
|
||||
test("the window frame takes the stored theme, and auto follows the system", () => {
|
||||
assert.equal(themeSourceFor({ "rx-theme": "dark" }), "dark");
|
||||
assert.equal(themeSourceFor({ "rx-theme": "light" }), "light");
|
||||
assert.equal(themeSourceFor({ "rx-theme": "auto" }), "system");
|
||||
assert.equal(themeSourceFor({ "rx-theme": "midnight" }), "system");
|
||||
assert.equal(themeSourceFor(undefined), "system");
|
||||
});
|
||||
|
||||
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
|
||||
|
||||
test("hardware acceleration is off only when the saved preference says exactly that", () => {
|
||||
@@ -1987,6 +2128,44 @@ test("kernel acts: show brings the window forward, relaunch arms the restart, qu
|
||||
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
|
||||
});
|
||||
|
||||
test("kernel acts: a click that reaches the shell is written to its log, an unknown act is not", () => {
|
||||
const { actHandler } = require("../src/acts.js");
|
||||
const lines = [];
|
||||
const handle = actHandler({ app: { relaunch() {}, quit() {} }, showWindow() {}, log: (line) => lines.push(line) });
|
||||
for (const act of ["show", "unknown"]) handle(act);
|
||||
assert.deepEqual(lines, ["kernel act: show"]);
|
||||
});
|
||||
|
||||
test("raise: answers whether the window is focused once it has tried", () => {
|
||||
const { raise } = require("../src/raise.js");
|
||||
const make = (grants) => {
|
||||
let focused = false;
|
||||
return { show() {}, focus: () => { if (grants()) focused = true; }, isFocused: () => focused, setAlwaysOnTop() {} };
|
||||
};
|
||||
assert.equal(raise(make(() => true), "win32"), true);
|
||||
assert.equal(raise(make(() => false), "win32"), false);
|
||||
});
|
||||
|
||||
test("raise: a window the platform would not focus is lifted once and returned to normal stacking", () => {
|
||||
const { raise } = require("../src/raise.js");
|
||||
const run = (platform, focusedAfterFocus) => {
|
||||
const calls = [];
|
||||
let focused = false;
|
||||
const win = {
|
||||
show: () => calls.push("show"),
|
||||
focus: () => { calls.push("focus"); if (focusedAfterFocus) focused = true; },
|
||||
isFocused: () => focused,
|
||||
setAlwaysOnTop: (on) => calls.push(on ? "top" : "untop"),
|
||||
};
|
||||
raise(win, platform);
|
||||
return calls;
|
||||
};
|
||||
assert.deepEqual(run("win32", true), ["show", "focus"]);
|
||||
assert.deepEqual(run("win32", false), ["show", "focus", "top", "focus", "untop"]);
|
||||
assert.deepEqual(run("darwin", false), ["show", "focus"]);
|
||||
assert.deepEqual(run("linux", false), ["show", "focus"]);
|
||||
});
|
||||
|
||||
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
|
||||
const { installTray } = require("../src/tray.js");
|
||||
const menus = [];
|
||||
@@ -2049,3 +2228,216 @@ test("tray: a refused close-to-tray save redraws the checkbox, and a menu that f
|
||||
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
|
||||
tray.close();
|
||||
});
|
||||
|
||||
test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => (opened.push(target), "") };
|
||||
assert.equal(await revealLogs(shell, "/user/logs"), null);
|
||||
assert.deepEqual(opened, ["/user/logs"]);
|
||||
const refusing = { openPath: async () => "no application is associated with this folder" };
|
||||
assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
|
||||
assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
|
||||
});
|
||||
|
||||
// The built-in browser drew a white page after switching away from a tab and
|
||||
// back until the window was resized: a parked view moved onscreen with
|
||||
// setBounds alone does not repaint. Bringing it back attaches it again.
|
||||
test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const calls = [];
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
|
||||
views.entries.set("t1", { view, at: null });
|
||||
const rect = { x: 10, y: 20, width: 600, height: 400 };
|
||||
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
|
||||
views.hide();
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
|
||||
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
|
||||
savePrefs(file, record);
|
||||
assert.deepEqual(loadPrefs(file), record);
|
||||
});
|
||||
|
||||
const { setUnreadBadge } = require("../src/badge.js");
|
||||
|
||||
test("macOS marks and clears the application Dock badge", () => {
|
||||
const badges = [];
|
||||
const app = { dock: { setBadge: (text) => badges.push(text) } };
|
||||
setUnreadBadge(true, {}, { app }, "darwin");
|
||||
setUnreadBadge(false, {}, { app }, "darwin");
|
||||
assert.deepEqual(badges, ["•", ""]);
|
||||
});
|
||||
|
||||
test("Windows uses a red dot overlay and removes it when all sessions are read", () => {
|
||||
const calls = [];
|
||||
const win = { setOverlayIcon: (...args) => calls.push(args) };
|
||||
let pixels;
|
||||
const nativeImage = { createFromBitmap: (buffer, size) => { pixels = buffer; assert.deepEqual(size, { width: 16, height: 16 }); return "dot"; } };
|
||||
setUnreadBadge(true, win, { nativeImage }, "win32");
|
||||
setUnreadBadge(false, win, { nativeImage }, "win32");
|
||||
const at = (x, y) => [...pixels.subarray((y * 16 + x) * 4, (y * 16 + x) * 4 + 4)];
|
||||
assert.equal(pixels[3], 0);
|
||||
assert.deepEqual(at(8, 8), [64, 64, 224, 255]);
|
||||
assert.equal(at(1, 8)[3], 0, "the dot stays well inside the 16px canvas");
|
||||
assert.deepEqual(at(12, 10), [255, 255, 255, 255], "a white ring separates it from the icon");
|
||||
assert.ok(at(13, 8)[3] > 0 && at(13, 8)[3] < 255, "the rim is smoothed, not jagged");
|
||||
assert.deepEqual(calls, [["dot", "Reasonix Studio"], [null, ""]]);
|
||||
});
|
||||
|
||||
test("Linux clears its launcher badge without requiring Dock APIs", () => {
|
||||
const counts = [];
|
||||
const app = { setBadgeCount: (count) => counts.push(count) };
|
||||
setUnreadBadge(true, {}, { app }, "linux");
|
||||
setUnreadBadge(false, {}, { app }, "linux");
|
||||
assert.deepEqual(counts, [1, 0]);
|
||||
});
|
||||
|
||||
test("CUA readiness refuses a different process, host or driver version", () => {
|
||||
const { accepts } = require("../src/cua");
|
||||
const result = { pid: 123, embedded: true, host_bundle_id: "io.reasonix.studio", driver_version: "0.34.1", contract_version: "0.8.0", tools_list_schema_version: "1", capability_version: "1", mcp_protocol_version: "2025-06-18" };
|
||||
assert.equal(accepts({ ok: true, result }, 123, cuaContract), true);
|
||||
for (const [key, value] of Object.entries({ pid: 124, embedded: false, host_bundle_id: "other", driver_version: "0.35.0", contract_version: "0.9.0", mcp_protocol_version: "2026-07-28" })) {
|
||||
assert.equal(accepts({ ok: true, result: { ...result, [key]: value } }, 123, cuaContract), false);
|
||||
}
|
||||
assert.equal(accepts({ ok: false, result }, 123), false);
|
||||
});
|
||||
|
||||
|
||||
test("CUA interruption waits for the owned service to stop", async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const { stopProcess } = require("../src/cua");
|
||||
const child = spawn(process.execPath, ["-e", "process.stdin.resume();setInterval(()=>{},1000);process.stdin.on('end',()=>setTimeout(()=>process.exit(0),80))"], { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
|
||||
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
||||
await stopProcess(child, true);
|
||||
assert.ok(child.exitCode !== null || child.signalCode !== null);
|
||||
});
|
||||
|
||||
test("CUA embedded control stops the old service before admitting a fresh one", { skip: process.platform === "win32" }, async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const net = require("node:net");
|
||||
const { startEmbedded, metadata } = require("../src/cua");
|
||||
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
|
||||
const owned = await startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => spawn(process.execPath, [fake, ...args], options));
|
||||
try {
|
||||
const before = await metadata(owned.socket);
|
||||
const reply = await new Promise((resolve, reject) => {
|
||||
const peer = net.createConnection(owned.abortSocket);
|
||||
let text = "";
|
||||
peer.once("error", reject);
|
||||
peer.on("connect", () => peer.write('{"op":"abort"}\n'));
|
||||
peer.on("data", bytes => { text += bytes; if (text.includes("\n")) { peer.destroy(); resolve(JSON.parse(text)); } });
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
const after = await metadata(owned.socket);
|
||||
assert.notEqual(after.result.pid, before.result.pid);
|
||||
assert.throws(() => process.kill(before.result.pid, 0));
|
||||
} finally { await owned.stop(); }
|
||||
assert.equal(fs.existsSync(owned.socket), false);
|
||||
assert.equal(fs.existsSync(owned.abortSocket), false);
|
||||
});
|
||||
|
||||
|
||||
test("CUA repeated quit waits for owned driver cleanup", async () => {
|
||||
const { createQuitGate } = require("../src/cua");
|
||||
let resolveStop;
|
||||
const stopped = new Promise(resolve => { resolveStop = resolve; });
|
||||
let stops = 0, quits = 0, prevented = 0;
|
||||
const gate = createQuitGate(() => ({ stop: () => { stops++; return stopped; } }), () => { quits++; }, assert.fail);
|
||||
const event = { preventDefault: () => { prevented++; } };
|
||||
gate(event);
|
||||
gate(event);
|
||||
await Promise.resolve();
|
||||
assert.equal(stops, 1);
|
||||
assert.equal(prevented, 2);
|
||||
assert.equal(quits, 0);
|
||||
resolveStop();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(quits, 1);
|
||||
gate(event);
|
||||
assert.equal(prevented, 2);
|
||||
});
|
||||
|
||||
|
||||
test("CUA extraction handles ZIP archives when GNU tar precedes the Windows reader", { skip: process.platform !== "win32" || !fs.existsSync("C:/Program Files/Git/usr/bin/tar.exe") }, () => {
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const tar = "C:/Program Files/Git/usr/bin/tar.exe";
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-cua-extract-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(stage, "driver.zip"), Buffer.from("UEsDBBQAAAAIANomTV3uQOUFCQAAAAcAAAAOAAAAY3VhLWRyaXZlci5leGVLy6woKS1KBQBQSwECFAAUAAAACADaJk1d7kDlBQkAAAAHAAAADgAAAAAAAAAAAAAAgAEAAAAAY3VhLWRyaXZlci5leGVQSwUGAAAAAAEAAQA8AAAANQAAAAAA", "base64"));
|
||||
const target = path.join(stage, "extracted");
|
||||
fs.mkdirSync(target);
|
||||
const script = `require(${JSON.stringify(require.resolve("../packaging/install-cua-driver"))}).extract(process.argv[1], process.argv[2])`;
|
||||
const unpacked = spawnSync(process.execPath, ["-e", script, path.join(stage, "driver.zip"), target], { env: { ...process.env, PATH: path.dirname(tar) + path.delimiter + process.env.PATH } });
|
||||
assert.equal(unpacked.status, 0, unpacked.stderr.toString());
|
||||
assert.equal(fs.readFileSync(path.join(target, "cua-driver.exe"), "utf8"), "fixture");
|
||||
} finally {
|
||||
assert.ok(path.resolve(stage).startsWith(path.resolve(os.tmpdir()) + path.sep));
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("CUA layout resolves one canonical contract in development and packaging", () => {
|
||||
const { computerDriverContract } = require("../src/layout");
|
||||
const dirname = path.dirname(require("node:url").fileURLToPath(import.meta.url));
|
||||
const source = computerDriverContract({ packaged: false, dirname: path.join(dirname, "../src") });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(source, "utf8")), cuaContract);
|
||||
assert.equal(computerDriverContract({ packaged: true, resourcesPath: "/res" }), path.join("/res", "bin", "cua-driver.lock.json"));
|
||||
});
|
||||
|
||||
const { captureHandler, MAX_WIDTH } = require("../src/capture.js");
|
||||
|
||||
function fakeImage(width, { empty = false } = {}) {
|
||||
return {
|
||||
isEmpty: () => empty,
|
||||
getSize: () => ({ width, height: 10 }),
|
||||
resize: (opts) => fakeImage(opts.width),
|
||||
toPNG: () => Buffer.from(`png:${width}`),
|
||||
};
|
||||
}
|
||||
|
||||
test("the window capture answers only the studio window and takes no target", async () => {
|
||||
let asked = 0;
|
||||
const win = { webContents: { capturePage: async (...args) => (asked++, assert.equal(args.length, 0), fakeImage(800)) } };
|
||||
const handle = captureHandler((event) => (event.sender === "studio" ? win : null));
|
||||
const png = await handle({ sender: "studio" }, "some-other-window-id", { x: 0, y: 0 });
|
||||
assert.equal(Buffer.from(png).toString(), "png:800");
|
||||
assert.equal(await handle({ sender: "a browser page" }), null);
|
||||
assert.equal(asked, 1);
|
||||
});
|
||||
|
||||
test("the window capture is scaled down past the upload-friendly width and refuses an empty frame", async () => {
|
||||
const wide = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(MAX_WIDTH * 2) } }));
|
||||
assert.equal(Buffer.from(await wide({})).toString(), `png:${MAX_WIDTH}`);
|
||||
const blank = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(0, { empty: true }) } }));
|
||||
assert.equal(await blank({}), null);
|
||||
});
|
||||
@@ -2,4 +2,5 @@ node_modules
|
||||
dist
|
||||
dist-perf
|
||||
*.png
|
||||
!public/icons/*.png
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -7,6 +7,13 @@
|
||||
<link rel="preconnect" href="https://remote.reasonix.io" crossorigin />
|
||||
<link rel="dns-prefetch" href="//id.reasonix.io" />
|
||||
<link rel="dns-prefetch" href="//remote.reasonix.io" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#07080c" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Reasonix" />
|
||||
<title>Reasonix Studio</title>
|
||||
<script>
|
||||
// The boot screen below is painted by this file, before the bundle that
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"census:verify": "node tools/ui-census/verify.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2ui/react": "0.13.0",
|
||||
"@a2ui/web_core": "0.13.0",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
@@ -30,18 +32,26 @@
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.2",
|
||||
"@mantine/core": "9.7.2",
|
||||
"@mantine/hooks": "9.7.2",
|
||||
"@noble/curves": "2.4.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.19.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gemoji": "^8.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0"
|
||||
"remark-math": "^6.0.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.7",
|
||||
|
||||
@@ -61,6 +61,7 @@ node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,
|
||||
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
|
||||
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
|
||||
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
|
||||
node perf/text-glow.mjs # Transcript glow, pack switching and prose contrast across schemes, wallpapers, widths, effects and motion
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
@@ -74,6 +75,10 @@ node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行
|
||||
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
|
||||
node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
|
||||
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
|
||||
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Adding a project selects it and opens a new session there: the sidebar marks
|
||||
// the new folder as the current one, and no other folder keeps that mark.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.ADDPROJECT_SHOTS;
|
||||
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".machpick");
|
||||
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
|
||||
const was = await current();
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
|
||||
await page.locator(".machpick").first().click();
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
|
||||
const now = await current();
|
||||
check(`${scheme}: the added project becomes the current one`, now.length === 1 && now[0].includes("mock-workspace"), now.join("|").slice(0, 120));
|
||||
check(`${scheme}: it was not already current`, !was.some((text) => text.includes("mock-workspace")));
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-after.png`) });
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The "+" that opens a project folder sits on the machine row where a person
|
||||
// looks for it, and is there before the row is hovered. It was drawn only on
|
||||
// hover once, and two people on 2.39.0 reported that they could not choose a
|
||||
// folder at all. Opacity and hit-testing need a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const width of [1280, 1600]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".machrow[data-here]");
|
||||
await page.mouse.move(width - 2, 790);
|
||||
await page.waitForTimeout(300);
|
||||
const g = await page.evaluate(() => {
|
||||
const row = document.querySelector(".machrow[data-here]");
|
||||
const add = row?.querySelector('[data-action="workspace.add"]');
|
||||
if (!add) return null;
|
||||
const r = add.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
let seen = 1;
|
||||
for (let el = add; el && el !== document.body; el = el.parentElement) seen *= Number(getComputedStyle(el).opacity);
|
||||
return { opacity: seen, width: r.width, height: r.height, hits: hit === add || add.contains(hit), shown: row.matches(":hover") };
|
||||
});
|
||||
const where = `${theme} ${width}`;
|
||||
check(`${where}: the machine row has the add-folder button`, g !== null);
|
||||
if (!g) continue;
|
||||
check(`${where}: it is visible without hovering the row`, !g.shown && g.opacity === 1 && g.width > 0 && g.height > 0, JSON.stringify(g));
|
||||
check(`${where}: it takes the pointer`, g.hits);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,35 @@
|
||||
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing:
|
||||
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 20000 });
|
||||
const chip = page.locator('.studio-branch-pop .studio-branch').first();
|
||||
await chip.waitFor();
|
||||
const card = page.locator(".studio-branch-card").first();
|
||||
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
|
||||
await chip.hover();
|
||||
await page.waitForTimeout(400);
|
||||
check("the hover card shows over a closed chip", await shown());
|
||||
await chip.click();
|
||||
await page.waitForTimeout(500);
|
||||
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
|
||||
check("the hover card is gone while the menu is open", !(await shown()));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
|
||||
}
|
||||
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
|
||||
|
||||
// An untouched phone composer leaves room to read; focus or a draft restores
|
||||
// the editing controls without replacing the textarea or its caret.
|
||||
const phone = () => page.evaluate(() => {
|
||||
const box = document.querySelector('textarea[role="combobox"]');
|
||||
const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
|
||||
const tools = [...document.querySelectorAll(".turntools > *")]
|
||||
.filter((el) => el.getBoundingClientRect().height > 0);
|
||||
const rows = new Set(tools.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.round((r.top + r.height / 2) / 2);
|
||||
}));
|
||||
return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
|
||||
refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
|
||||
});
|
||||
await page.fill(BOX, "");
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(BOX);
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quiet = await phone();
|
||||
check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
|
||||
check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
|
||||
check("手机:空置工具栏只占一行", quiet.rows === 1);
|
||||
await page.click(BOX);
|
||||
await frame();
|
||||
const focused = await phone();
|
||||
check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
|
||||
await page.fill(BOX, "保留草稿\n第二行\n第三行");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const draft = await phone();
|
||||
check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
|
||||
check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
|
||||
await page.fill(BOX, "");
|
||||
await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
|
||||
await page.waitForSelector(".shots");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quoted = await phone();
|
||||
check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// The session title sits right beside the project name in the title bar, at every width, and the
|
||||
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [1400, 1000, 700]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 640 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".chrome .crumb-proj");
|
||||
const at = await page.evaluate(() => {
|
||||
const box = (sel) => document.querySelector(sel).getBoundingClientRect();
|
||||
const sep = box(".chrome .crumbsep"), title = box(".chrome .crumb > b");
|
||||
const projEl = document.querySelector(".chrome .crumb-proj");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(projEl);
|
||||
const text = range.getBoundingClientRect();
|
||||
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
|
||||
});
|
||||
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
|
||||
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
|
||||
const long = await page.evaluate(() => {
|
||||
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
|
||||
document.querySelector(".chrome .crumb > b").textContent = "a very long session title ".repeat(8);
|
||||
const crumb = document.querySelector(".chrome .crumb").getBoundingClientRect();
|
||||
const title = document.querySelector(".chrome .crumb > b").getBoundingClientRect();
|
||||
return { overflow: Math.round(title.right - crumb.right), titleWidth: Math.round(title.width) };
|
||||
});
|
||||
check(`${width}px: long names stay inside the title bar`, long.overflow <= 0 && long.titleWidth > 0, `title overflows by ${long.overflow}px`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Settings and feedback centre on the window, not on the area below the title
|
||||
// bar; a phone-width window keeps settings full screen. Geometry needs a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const box = (page, sel) =>
|
||||
page.evaluate((s) => {
|
||||
const r = document.querySelector(s)?.getBoundingClientRect();
|
||||
return r ? { top: Math.round(r.top), bottom: Math.round(innerHeight - r.bottom), height: Math.round(r.height) } : null;
|
||||
}, sel);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?turns=5&pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.waitForSelector(".prefs-sheet");
|
||||
await page.waitForTimeout(300);
|
||||
const prefs = await box(page, ".prefs-sheet");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".prefs-sheet", { state: "detached" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="feedback.open"]')?.click());
|
||||
await page.waitForSelector(".fbk");
|
||||
await page.waitForTimeout(300);
|
||||
const fbk = await box(page, ".fbk");
|
||||
const tag = `${w}x${h}`;
|
||||
if (w > 760) {
|
||||
check(`${tag}: settings sits in the window's vertical middle`, Math.abs(prefs.top - prefs.bottom) <= 2, JSON.stringify(prefs));
|
||||
check(`${tag}: feedback sits in the window's vertical middle`, Math.abs(fbk.top - fbk.bottom) <= 2, JSON.stringify(fbk));
|
||||
} else {
|
||||
check(`${tag}: settings still reaches the bottom of a phone-width window`, prefs.bottom === 0, JSON.stringify(prefs));
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -17,7 +17,9 @@ const geometry = (page) =>
|
||||
const r = el?.getBoundingClientRect();
|
||||
return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
|
||||
};
|
||||
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
|
||||
const card = document.querySelector('.pane > .scroll[data-pane="browser"] > .workbench');
|
||||
const divider = card ? parseFloat(getComputedStyle(card).borderLeftWidth) : 0;
|
||||
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
@@ -31,7 +33,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
for (const round of ["first open", "after a reload"]) {
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const open = await geometry(page);
|
||||
docked = open;
|
||||
@@ -41,13 +43,15 @@ for (const scheme of ["light", "dark"]) {
|
||||
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
|
||||
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
|
||||
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
|
||||
check(`${tag} ${round}: its strip ends on the navigation row's line, so the two rows read as one`, !!open.strip && Math.abs(open.strip.bottom - open.tabs.bottom) <= 1, JSON.stringify([open.tabs, open.strip]));
|
||||
check(`${tag} ${round}: the workbench card's own edge separates it from the conversation`, open.divider >= 1, String(open.divider));
|
||||
} else {
|
||||
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
|
||||
}
|
||||
}
|
||||
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
}
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const shut = await geometry(page);
|
||||
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
|
||||
@@ -63,7 +67,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".pane > .tabs");
|
||||
|
||||
@@ -134,6 +134,26 @@ const timeLoops = (page) =>
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
// The sidebar's footer is part of the rail: a plate of its own over the
|
||||
// wallpaper reads as a second, whiter panel at the window's lower left.
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1200, height: 760 }, colorScheme: scheme });
|
||||
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "off"));
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=photo`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.mouse.click(8, 407);
|
||||
await page.waitForSelector(".railfoot", { timeout: 5000 });
|
||||
const paint = await page.evaluate(() => {
|
||||
const alpha = (c) => (c.startsWith("rgba(") ? Number(c.split(",")[3].replace(")", "")) : c.includes("/") ? Number(c.split("/")[1].replace(")", "")) : 1);
|
||||
return alpha(getComputedStyle(document.querySelector(".railfoot")).backgroundColor);
|
||||
});
|
||||
check(`${scheme}: the sidebar footer paints no plate over the wallpaper`, paint === 0, `alpha ${paint}`);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,49 @@
|
||||
// The explorer in the workbench is read at arm's length, next to a conversation set in a 14px reading size.
|
||||
// Its header, search field, file rows and the icons beside the count must stay big enough to read and to hit.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
await page.waitForSelector(".workbench-explorer-head");
|
||||
await page.waitForSelector(".workbench-filelist button");
|
||||
const read = await page.evaluate(() => {
|
||||
const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]);
|
||||
const head = document.querySelector(".workbench-explorer-head");
|
||||
const btn = head.querySelector(".workbench-open-editor");
|
||||
const row = document.querySelector(".workbench-filelist button");
|
||||
const search = document.querySelector(".workbench-search input");
|
||||
return {
|
||||
head: px(head, "fontSize"),
|
||||
button: btn.getBoundingClientRect().width,
|
||||
icon: btn.querySelector("svg").getBoundingClientRect().width,
|
||||
row: px(row, "fontSize"),
|
||||
search: px(search, "fontSize"),
|
||||
rowIcon: row.querySelector("svg").getBoundingClientRect().width,
|
||||
};
|
||||
});
|
||||
check(`${scheme}: the header is at least 12px`, read.head >= 12, `${read.head}px`);
|
||||
check(`${scheme}: the header buttons are at least 28px with 16px icons`, read.button >= 28 && read.icon >= 16, `${read.button}px / ${read.icon}px`);
|
||||
check(`${scheme}: file rows are at least 12px with 15px icons`, read.row >= 12 && read.rowIcon >= 15, `${read.row}px / ${read.rowIcon}px`);
|
||||
check(`${scheme}: the search field is at least 12px`, read.search >= 12, `${read.search}px`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
Loaded 100 of 1561 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user