mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
696
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95da89e081 | ||
|
|
2895ba3249 | ||
|
|
5573356405 | ||
|
|
a44df06755 | ||
|
|
4d3dbd69f6 | ||
|
|
533e75933e | ||
|
|
d87d0d959e | ||
|
|
f0ce91a097 | ||
|
|
ea43bb3014 | ||
|
|
66c923f4a8 | ||
|
|
e2163d96e6 | ||
|
|
a3d2f761ce | ||
|
|
dbad5a9b3a | ||
|
|
f54af950c9 | ||
|
|
1b4e253791 | ||
|
|
7588b5ebee | ||
|
|
164d78aa27 | ||
|
|
4c31478ae5 | ||
|
|
7804257a1f | ||
|
|
263bec799e | ||
|
|
22632b359f | ||
|
|
da57c88a62 | ||
|
|
cad3c58f8a | ||
|
|
793bb263a3 | ||
|
|
5a292a1392 | ||
|
|
35713ed46e | ||
|
|
b326c1e0f5 | ||
|
|
57bf125488 | ||
|
|
ac1a737eef | ||
|
|
e67fe2ce96 | ||
|
|
a9b5a74306 | ||
|
|
e64f7f50cd | ||
|
|
0c3539814d | ||
|
|
05acef3f03 | ||
|
|
cd21b99e82 | ||
|
|
21c7ff3691 | ||
|
|
8ad50df92b | ||
|
|
85cf456da1 | ||
|
|
9b50a7cf6b | ||
|
|
ff4066b56d | ||
|
|
958f8c7e97 | ||
|
|
c7be40d404 | ||
|
|
1e77d19966 | ||
|
|
5e44450ac4 | ||
|
|
cd74ec3a60 | ||
|
|
8bf2ca8bb1 | ||
|
|
e84a047949 | ||
|
|
f1f5031f79 | ||
|
|
7f4e86d3c0 | ||
|
|
c968232e5e | ||
|
|
14cd0438f9 | ||
|
|
4b4292a4a4 | ||
|
|
e072a874d8 | ||
|
|
561f1c2726 | ||
|
|
ed1dfebb9e | ||
|
|
ba48dfe0b9 | ||
|
|
ff37e78d7d | ||
|
|
22f65521a8 | ||
|
|
f95955c781 | ||
|
|
abff4c7768 | ||
|
|
10d3a411ed | ||
|
|
c6e5891e26 | ||
|
|
2f24a1b693 | ||
|
|
59fc0b6520 | ||
|
|
17fa87d048 | ||
|
|
9bb02269b2 | ||
|
|
3670d56d9e | ||
|
|
6b723daf0d | ||
|
|
28650f0615 | ||
|
|
4e59ccc796 | ||
|
|
9a9fa80242 | ||
|
|
42c6808c94 | ||
|
|
f040da0929 | ||
|
|
0abdc91b9e | ||
|
|
fab0f72a2f | ||
|
|
8c95b2cb64 | ||
|
|
7db2857171 | ||
|
|
f8ec0a7795 | ||
|
|
0459e81d9c | ||
|
|
eadc7b1883 | ||
|
|
5b2e61d153 | ||
|
|
116162df9c | ||
|
|
835738dfec | ||
|
|
2ea83e3c0e | ||
|
|
aafdf0bc0d | ||
|
|
9fb6dbd5ae | ||
|
|
23be188e9e | ||
|
|
1786c1e3cc | ||
|
|
6906e31e47 | ||
|
|
58ec7d40f3 | ||
|
|
5ff43981d4 | ||
|
|
941c74a8a4 | ||
|
|
724fdfd87e | ||
|
|
92bfe8fc4e | ||
|
|
c8c8d8e341 | ||
|
|
d2f919736b | ||
|
|
16ba8d38bf | ||
|
|
cda566e404 | ||
|
|
a00519304e | ||
|
|
0b8de9e636 | ||
|
|
564716a091 | ||
|
|
6c631b0870 | ||
|
|
c3daadf167 | ||
|
|
938b20e7a3 | ||
|
|
a3dab1bff1 | ||
|
|
b5a8cfd299 | ||
|
|
42636ddcb4 | ||
|
|
da14d60b31 | ||
|
|
8df4882d16 | ||
|
|
5211538354 | ||
|
|
17327b4875 | ||
|
|
9023835220 | ||
|
|
5ea0ed7046 | ||
|
|
805d9d189a | ||
|
|
23bf613596 | ||
|
|
9fe36abc5b | ||
|
|
b9d4dbadcc | ||
|
|
9a7460ac87 | ||
|
|
17ad4fd742 | ||
|
|
a655a9fbe3 | ||
|
|
3d9f9ff74c | ||
|
|
1fb85da958 | ||
|
|
c9d33ca8cb | ||
|
|
3e8c1dad79 | ||
|
|
16b91bc1ad | ||
|
|
7905358552 | ||
|
|
cffde3f3e9 | ||
|
|
036b25d499 | ||
|
|
ce06cec619 | ||
|
|
81521fa074 | ||
|
|
d8e55c6fd4 | ||
|
|
88092a4c8b | ||
|
|
53a8db913a | ||
|
|
145b068da1 | ||
|
|
bf4f3f44f0 | ||
|
|
6add7ebfc9 | ||
|
|
f43ef4fd44 | ||
|
|
ce3c8b9b14 | ||
|
|
e3449c11ad | ||
|
|
cca093601b | ||
|
|
78d1233283 | ||
|
|
9552fbbfdc | ||
|
|
67e3ff51b4 | ||
|
|
6d171bacda | ||
|
|
eaaf2ce99d | ||
|
|
f8f670fabb | ||
|
|
0c5e8e2264 | ||
|
|
a47ffb4134 | ||
|
|
8376709ee3 | ||
|
|
60aba758a8 | ||
|
|
13e007e845 | ||
|
|
47bf3ce5db | ||
|
|
b5f18a7be9 | ||
|
|
581b367f04 | ||
|
|
5238af4629 | ||
|
|
0afe5434a1 | ||
|
|
214a8871da | ||
|
|
ab5dfe515f | ||
|
|
28bd6a44d8 | ||
|
|
e3f1e64dcd | ||
|
|
730c2ab9fe | ||
|
|
26830b037f | ||
|
|
63e7e13f69 | ||
|
|
432a49fe73 | ||
|
|
ba2460c01b | ||
|
|
67854e87b6 | ||
|
|
092a375576 | ||
|
|
4892a8875e | ||
|
|
b2b7b78aa2 | ||
|
|
f7ea4be898 | ||
|
|
5e1aef3260 | ||
|
|
675c093dbc | ||
|
|
156cd28a5d | ||
|
|
f2110ddc9c | ||
|
|
4a005015ac | ||
|
|
947907bb77 | ||
|
|
bd9df22bc6 | ||
|
|
b6c837b0af | ||
|
|
0e0eecf2b6 | ||
|
|
164c03c141 | ||
|
|
62afcaddf5 | ||
|
|
5b1c1aeadd | ||
|
|
a8fc3c05b7 | ||
|
|
0a78408ea9 | ||
|
|
1e1fb59a60 | ||
|
|
d18912cd30 | ||
|
|
22c3c2b167 | ||
|
|
cfb483ae11 | ||
|
|
36370578c9 | ||
|
|
e0e1f7fa55 | ||
|
|
118f8a624a | ||
|
|
f987d4326b | ||
|
|
d67d18df49 | ||
|
|
039756a281 | ||
|
|
8bd8220792 | ||
|
|
db653b55f2 | ||
|
|
0e56db4b59 | ||
|
|
bf3fcd9934 | ||
|
|
a7cf278566 | ||
|
|
399a6b62e1 | ||
|
|
f7b9f2db90 | ||
|
|
a304b5d426 | ||
|
|
4a3cca633b | ||
|
|
963d45aeec | ||
|
|
d249fd9ae3 | ||
|
|
3a3b3411b9 | ||
|
|
4cfe4b1d2f | ||
|
|
252ed5f28b | ||
|
|
94c1eb913d | ||
|
|
da2f54207c | ||
|
|
56795d8913 | ||
|
|
5163088956 | ||
|
|
a0589c8ad0 | ||
|
|
45444a47cb | ||
|
|
6318ab0ae4 | ||
|
|
d536418db3 | ||
|
|
77134b5ef0 | ||
|
|
8d714ba296 | ||
|
|
f73a5b1507 | ||
|
|
814dbd5402 | ||
|
|
fdaf05584f | ||
|
|
dfa8f70ea4 | ||
|
|
54ca8a7847 | ||
|
|
f2ac8905ad | ||
|
|
1b25fca477 | ||
|
|
fe6c7c3a91 | ||
|
|
e741c70074 | ||
|
|
c0d08b437c | ||
|
|
f800585f31 | ||
|
|
45c763ce15 | ||
|
|
8e1ea87adc | ||
|
|
30c63e6023 | ||
|
|
80fcd85b54 | ||
|
|
0ce5ce6436 | ||
|
|
27ad7ded21 | ||
|
|
fe0020056e | ||
|
|
f5d2874d61 | ||
|
|
de2763b21e | ||
|
|
3b54ee9438 | ||
|
|
0d50803ab1 | ||
|
|
d8abd5b5ce | ||
|
|
e6ec598a58 | ||
|
|
78cb353fd8 | ||
|
|
bf1ff861ad | ||
|
|
a925403b6f | ||
|
|
38060091bd | ||
|
|
d5997b6496 | ||
|
|
0b848ac7f2 | ||
|
|
d4089914ea | ||
|
|
7b499ffaeb | ||
|
|
5d6743aa2c | ||
|
|
add01e5f04 | ||
|
|
223a158a02 | ||
|
|
ad7e7f835f | ||
|
|
481aa9f9a6 | ||
|
|
1766c04904 | ||
|
|
ea0538a72e | ||
|
|
5888922ffc | ||
|
|
94e236f203 | ||
|
|
8ce086e1bc | ||
|
|
e1e5301681 | ||
|
|
7b8f7de8b0 | ||
|
|
fe45ff0a8c | ||
|
|
ae9fba3fd0 | ||
|
|
9710592281 | ||
|
|
ebe4f2dca2 | ||
|
|
817e3fa03d | ||
|
|
442f05cd11 | ||
|
|
8126f5cf1e | ||
|
|
904ca4359b | ||
|
|
c5e8080866 | ||
|
|
e4a0841fec | ||
|
|
053247aa1e | ||
|
|
ab66297bab | ||
|
|
02341bf950 | ||
|
|
a52b35551f | ||
|
|
edc7e01bb4 | ||
|
|
a07ca77b69 | ||
|
|
6b653a7f32 | ||
|
|
d8f9eef43d | ||
|
|
89d86f4d3f | ||
|
|
ae6a9de150 | ||
|
|
f8e16bfc29 | ||
|
|
30c6589451 | ||
|
|
0608ffeb63 | ||
|
|
2e2b316d86 | ||
|
|
da9fbe7307 | ||
|
|
c38beaf09a | ||
|
|
a3a8c1ff79 | ||
|
|
aa60b7f48b | ||
|
|
040aa7dbde | ||
|
|
ad9219543b | ||
|
|
5f4c9ffad7 | ||
|
|
e5be3420b3 | ||
|
|
d130baa87c | ||
|
|
d294509df6 | ||
|
|
18d679077d | ||
|
|
a81f06208c | ||
|
|
617a7c40d6 | ||
|
|
344db1d16b | ||
|
|
aa34e20192 | ||
|
|
9f19b31fdf | ||
|
|
8dcbc55218 | ||
|
|
8993a8eec1 | ||
|
|
a40d996915 | ||
|
|
1119118780 | ||
|
|
92b39e3ed0 | ||
|
|
6b8928a8c3 | ||
|
|
6b0c9ef061 | ||
|
|
6e9294dd99 | ||
|
|
38609899ae | ||
|
|
424343a8b4 | ||
|
|
118310d400 | ||
|
|
334598db44 | ||
|
|
c58a1d7cb3 | ||
|
|
5b3968c6f5 | ||
|
|
26d7a21bf6 | ||
|
|
f676e1d61a | ||
|
|
22bfd09c40 | ||
|
|
82b9616285 | ||
|
|
14ea889c81 | ||
|
|
d73352f4a6 | ||
|
|
751def6d32 | ||
|
|
d8f84ed690 | ||
|
|
a26328f24b | ||
|
|
f7bc03da36 | ||
|
|
79d971d607 | ||
|
|
97d7009cbb | ||
|
|
ba25d7c854 | ||
|
|
002b09a921 | ||
|
|
db1401cb5b | ||
|
|
9111f39d97 | ||
|
|
7f21f635ab | ||
|
|
aa90f02c8a | ||
|
|
1599703966 | ||
|
|
bda7ee6772 | ||
|
|
7df0c74c55 | ||
|
|
63cfcee555 | ||
|
|
a151994365 | ||
|
|
5b1c282fed | ||
|
|
1b0b593f66 | ||
|
|
edbe588256 | ||
|
|
ed9a4ad1c9 | ||
|
|
31d39290ec | ||
|
|
245b85ec13 | ||
|
|
7533a016b2 | ||
|
|
f43d985bda | ||
|
|
0556027a94 | ||
|
|
44d3acfaf5 | ||
|
|
8f691039d3 | ||
|
|
bcd54401f5 | ||
|
|
2f3ef27558 | ||
|
|
e2c0aeb1a2 | ||
|
|
b5c891a593 | ||
|
|
88982d21ad | ||
|
|
6e91c80dac | ||
|
|
6509535600 | ||
|
|
c70464535b | ||
|
|
0cf0d10c2a | ||
|
|
54f4f5a8b6 | ||
|
|
68d3238e98 | ||
|
|
c88d1715d4 | ||
|
|
8078d62b03 | ||
|
|
944c61f9e6 | ||
|
|
3f98212608 | ||
|
|
37892a1738 | ||
|
|
32c09fc148 | ||
|
|
50161c6b31 | ||
|
|
2ce0c3f97a | ||
|
|
29220a5c18 | ||
|
|
ffa3d3df29 | ||
|
|
9e5c144b40 | ||
|
|
13938149d5 | ||
|
|
406bf96f1e | ||
|
|
3eadd288f9 | ||
|
|
9de84cee74 | ||
|
|
8ddda45b60 | ||
|
|
51eab7c487 | ||
|
|
62d64b2eb7 | ||
|
|
38d5748406 | ||
|
|
4fb505dedf | ||
|
|
9bb7a469d2 | ||
|
|
319f079a63 | ||
|
|
1f285edf60 | ||
|
|
81e0438de3 | ||
|
|
8dec43e259 | ||
|
|
db0685a360 | ||
|
|
4d094ce1f9 | ||
|
|
66d144dd90 | ||
|
|
a88c474cbd | ||
|
|
0c000c7ad4 | ||
|
|
2c8eb49a14 | ||
|
|
2a9743d790 | ||
|
|
96f261b398 | ||
|
|
4af9d05744 | ||
|
|
336cbc3c22 | ||
|
|
f28360eadc | ||
|
|
49abf8c521 | ||
|
|
f26e1c1359 | ||
|
|
965f331798 | ||
|
|
51db70d463 | ||
|
|
d91111647b | ||
|
|
50a235f0f7 | ||
|
|
dc409fc506 | ||
|
|
9e0c35100c | ||
|
|
5910c8ae10 | ||
|
|
08f01f2d79 | ||
|
|
d226841381 | ||
|
|
e32eaee455 | ||
|
|
d5cee35e81 | ||
|
|
1b556ddbc8 | ||
|
|
76e579923f | ||
|
|
97e4675986 | ||
|
|
dd6b73380d | ||
|
|
c4fa9c73f7 | ||
|
|
3da8e6b23e | ||
|
|
069e5b54f3 | ||
|
|
cbeed94933 | ||
|
|
1de856a0f6 | ||
|
|
d136ac2828 | ||
|
|
edcbc47c06 | ||
|
|
17f816d11c | ||
|
|
643902d35a | ||
|
|
8aa66a6521 | ||
|
|
447cc6ae39 | ||
|
|
b67222e1a3 | ||
|
|
75d8b75051 | ||
|
|
0363720bd2 | ||
|
|
6693788cc4 | ||
|
|
bc9d7a16ad | ||
|
|
5375b9c8f8 | ||
|
|
60938d36d9 | ||
|
|
d3139f5a4f | ||
|
|
cebdb37431 | ||
|
|
8951095309 | ||
|
|
01e775312e | ||
|
|
2cf8510775 | ||
|
|
e2e642a488 | ||
|
|
61d2cfd5a8 | ||
|
|
721b61eb40 | ||
|
|
70d164489f | ||
|
|
598de997fa | ||
|
|
f52a7fa8c7 | ||
|
|
e35b0f7f59 | ||
|
|
09f040098e | ||
|
|
e05b98e35a | ||
|
|
dcd4632f8f | ||
|
|
78e164b54c | ||
|
|
84e70760b7 | ||
|
|
4014c87a38 | ||
|
|
f3bcb117f7 | ||
|
|
4f80c12e2b | ||
|
|
8b58ce9eed | ||
|
|
f02ef7d620 | ||
|
|
79430cf8cf | ||
|
|
e22b515af1 | ||
|
|
8ba8c1cf72 | ||
|
|
34eb44fcd0 | ||
|
|
957bdaa323 | ||
|
|
2f28235ab6 | ||
|
|
7c56994e82 | ||
|
|
84d16a1508 | ||
|
|
36044d1515 | ||
|
|
1a11a6691d | ||
|
|
49e2a036e6 | ||
|
|
221061b759 | ||
|
|
119f392248 | ||
|
|
d4d7ef809e | ||
|
|
74b4c5d21b | ||
|
|
f975d35816 | ||
|
|
d70edac126 | ||
|
|
5bd0afb74d | ||
|
|
f9c6f37b78 | ||
|
|
ae0f9f8fee | ||
|
|
057d8094ba | ||
|
|
ab3f88ac9b | ||
|
|
de5627d5e0 | ||
|
|
137a5831e9 | ||
|
|
4d3c09211b | ||
|
|
2bf3bd7b7e | ||
|
|
9e74ed1806 | ||
|
|
c013a4f20b | ||
|
|
113523c5f8 | ||
|
|
0f13cf54c5 | ||
|
|
5c99c45ded | ||
|
|
92ca1b2180 | ||
|
|
dd9cf72abf | ||
|
|
6cd443fc94 | ||
|
|
34c17c5abf | ||
|
|
ae0af1d5ed | ||
|
|
ce73697390 | ||
|
|
3ed0deb61d | ||
|
|
f91eba7de2 | ||
|
|
89fbc59761 | ||
|
|
c9c258067d | ||
|
|
d235c1fce8 | ||
|
|
03725a6939 | ||
|
|
ff5242bcfc | ||
|
|
00ecd029d4 | ||
|
|
74772ab76a | ||
|
|
a67c05616d | ||
|
|
a280324b78 | ||
|
|
ddc01a3793 | ||
|
|
764fad7299 | ||
|
|
45f7b33165 | ||
|
|
fc4c0e8e5b | ||
|
|
4025ef24dc | ||
|
|
ed1c127d65 | ||
|
|
a94ba07bab | ||
|
|
cdb7d2318a | ||
|
|
b39058d42b | ||
|
|
d91e7f8d17 | ||
|
|
d31d8daf07 | ||
|
|
d821569545 | ||
|
|
4d7bd5e7a7 | ||
|
|
8b1895f05d | ||
|
|
c52d0c325c | ||
|
|
24c3fe0f87 | ||
|
|
adee10bd33 | ||
|
|
971346ff56 | ||
|
|
58a6432e55 | ||
|
|
035453edfb | ||
|
|
92c7f9d107 | ||
|
|
338264c6b7 | ||
|
|
905eb76143 | ||
|
|
fea2a123f6 | ||
|
|
7dad28cf6b | ||
|
|
00d6b85d34 | ||
|
|
95428e8f17 | ||
|
|
ec49147dee | ||
|
|
2234a3d68b | ||
|
|
f2a2da4120 | ||
|
|
2a035ffdb1 | ||
|
|
152db8582e | ||
|
|
3d624951db | ||
|
|
047b9e96b0 | ||
|
|
a21d8d8b64 | ||
|
|
3219135e7e | ||
|
|
674712eadf | ||
|
|
0154163c84 | ||
|
|
e906f7b4d5 | ||
|
|
749b7a6ede | ||
|
|
8d7a55e8ad | ||
|
|
89f422ad27 | ||
|
|
c73eb851bf | ||
|
|
2c23584ee4 | ||
|
|
6745de2eda | ||
|
|
c6a421a97f | ||
|
|
6ee9cd0069 | ||
|
|
46284a98f4 | ||
|
|
92b2e46f69 | ||
|
|
4cddc3390c | ||
|
|
e453fe539e | ||
|
|
20d807cb80 | ||
|
|
c85b77bee2 | ||
|
|
da001ded22 | ||
|
|
3d71e57313 | ||
|
|
fb99e0effd | ||
|
|
6d700b071b | ||
|
|
b09691402d | ||
|
|
b3ec1e2e87 | ||
|
|
896552483e | ||
|
|
e6ba328ad4 | ||
|
|
ba8900aba9 | ||
|
|
57ca36e635 | ||
|
|
5fdd45af4b | ||
|
|
cc629b6424 | ||
|
|
157ca8533c | ||
|
|
c190fe4bf4 | ||
|
|
6943f966da | ||
|
|
de67ca98e8 | ||
|
|
436fa1754a | ||
|
|
cf745b383c | ||
|
|
790943ac97 | ||
|
|
6f68a14a9d | ||
|
|
032e9b771d | ||
|
|
089a348753 | ||
|
|
d87dc94c26 | ||
|
|
7c35e9bfab | ||
|
|
e667bbcd59 | ||
|
|
9057c3211e | ||
|
|
b6dbbde594 | ||
|
|
065d5200da | ||
|
|
8e14cdff21 | ||
|
|
74e594faed | ||
|
|
6a93a3d01e | ||
|
|
f821e2be49 | ||
|
|
8ce6be77d8 | ||
|
|
e60cc114e8 | ||
|
|
bbe38032c3 | ||
|
|
bb43735206 | ||
|
|
1571fe64dc | ||
|
|
fc4626cf94 | ||
|
|
cbff25b46b | ||
|
|
33b05e79d8 | ||
|
|
cacc7c6789 | ||
|
|
3f61753054 | ||
|
|
92d8795b31 | ||
|
|
576e89b111 | ||
|
|
f249ab3e0e | ||
|
|
4618349369 | ||
|
|
27dbf237af | ||
|
|
83fde1922f | ||
|
|
8bebe521d6 | ||
|
|
eacf0618e8 | ||
|
|
8c311b02bd | ||
|
|
7b775ed15e | ||
|
|
8777a70d87 | ||
|
|
64c5ff3273 | ||
|
|
291d02c884 | ||
|
|
f40ab35660 | ||
|
|
b96ad582a6 | ||
|
|
42c505b50e | ||
|
|
5c38676052 | ||
|
|
58e2da8db2 | ||
|
|
4b483d3897 | ||
|
|
085f20c576 | ||
|
|
3e10e6faf6 | ||
|
|
8f1fe5965d | ||
|
|
f1fa8538d5 | ||
|
|
4d2c7cd4d0 | ||
|
|
863ed17e70 | ||
|
|
a2aef0b953 | ||
|
|
15de9ec334 | ||
|
|
ec207aabf7 | ||
|
|
bec46d99ab | ||
|
|
992e0155a5 | ||
|
|
3b5ef3dff1 | ||
|
|
33bb46e95a | ||
|
|
14206bc978 | ||
|
|
da0c5c6a9f | ||
|
|
038732325c | ||
|
|
ffe30b62bf | ||
|
|
eb48decd12 | ||
|
|
e7d19e3d65 | ||
|
|
2002ef72ea | ||
|
|
ddba148a36 | ||
|
|
8720820542 | ||
|
|
ec8a91e1e1 | ||
|
|
25d9f7f028 | ||
|
|
aa80cdce2e | ||
|
|
5b4f35502f | ||
|
|
fbdb85dc4e | ||
|
|
006616829a | ||
|
|
b9510a1cc2 | ||
|
|
0d9bae435a | ||
|
|
fbf2495a4c | ||
|
|
724d94af8c | ||
|
|
e24b481e40 | ||
|
|
4840df9825 | ||
|
|
8c4e0ef1e8 | ||
|
|
fadd893983 | ||
|
|
12fb14abb6 | ||
|
|
001d33f232 | ||
|
|
667f2ca166 | ||
|
|
33219e58c5 | ||
|
|
f6c18ac5a3 | ||
|
|
fc80457d14 | ||
|
|
5d99b3a628 | ||
|
|
96d21061db | ||
|
|
7392199b3c | ||
|
|
1d1a8c7ad8 | ||
|
|
fb84c5af25 | ||
|
|
c24492d2c0 | ||
|
|
c1c7d99ecb | ||
|
|
02ff3c3352 | ||
|
|
cc14bcb75e | ||
|
|
14c5dcbcee | ||
|
|
623e792df3 | ||
|
|
a8dc464e9a | ||
|
|
321e3bbaf0 | ||
|
|
5393e818e2 | ||
|
|
aab760b0b2 | ||
|
|
a72134c0ef | ||
|
|
530ccbf688 | ||
|
|
9c6c91da9c | ||
|
|
c7e6ef9a52 | ||
|
|
f3a5a6bcce | ||
|
|
f5ebeb1bd6 | ||
|
|
6fe0fd6867 | ||
|
|
f3930c0c7f | ||
|
|
e7d50cf0e9 | ||
|
|
e858a46f70 | ||
|
|
4522bb569a | ||
|
|
914bb02fc0 | ||
|
|
525d397009 | ||
|
|
eebc6d2a3c | ||
|
|
8143adc062 | ||
|
|
8709b556f0 | ||
|
|
f2e2f1f460 | ||
|
|
da051abb2c | ||
|
|
9717c4ff2c | ||
|
|
4fe345ec44 | ||
|
|
ce29aae765 | ||
|
|
27434c885f | ||
|
|
21d8cc2fa9 |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
*.bat text eol=crlf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.golden text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: acp-conformance
|
||||
|
||||
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
|
||||
# message it writes is held to the schema that SDK ships. Not a required check:
|
||||
# it runs when the ACP frontend or the harness changes, and on request.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main-v2, studio]
|
||||
paths:
|
||||
- "internal/frontend/acp/**"
|
||||
- "tools/acp-conformance/**"
|
||||
- ".github/workflows/acp-conformance.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: acp-conformance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
conformance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: make acp-conformance
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Deploy website
|
||||
|
||||
# GitHub requires workflow_dispatch to be registered on the default branch.
|
||||
# The production implementation is the same workflow path on website.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional product release version associated with this publication"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Optional release workflow correlation ID"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
studio_version:
|
||||
description: "Studio version that must be present in the generated changelog"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
require-website:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
echo "::error::Deploy the website with --ref website; production is restricted to that branch."
|
||||
exit 1
|
||||
@@ -389,6 +389,14 @@ jobs:
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
|
||||
|
||||
- name: Verify the AppImage
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
arch="${platform#linux/}"
|
||||
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
|
||||
|
||||
# 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
|
||||
@@ -1199,7 +1207,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\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\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 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
@@ -1294,6 +1302,25 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
website:
|
||||
name: publish the Studio website changelog
|
||||
needs: [resolve, publish]
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && needs.resolve.outputs.candidate != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
- name: Deploy and verify the website changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: bash scripts/publish-studio-changelog.sh
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
|
||||
@@ -144,7 +144,18 @@ jobs:
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/git-panel.mjs
|
||||
node perf/session-move-to.mjs
|
||||
node perf/shell-refused.mjs
|
||||
node perf/external-origin.mjs
|
||||
node perf/rail-search-hold.mjs
|
||||
node perf/rail-toggle-panes.mjs
|
||||
node perf/session-move.mjs
|
||||
node perf/session-ref.mjs
|
||||
node perf/browser-full.mjs
|
||||
node perf/mod-ui.mjs
|
||||
node perf/codeeditor-size.mjs
|
||||
node perf/action-icons.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
@@ -167,6 +178,11 @@ jobs:
|
||||
node perf/mcp-actions.mjs
|
||||
node perf/web-access.mjs
|
||||
node perf/peers-settings.mjs
|
||||
node perf/output-cap.mjs
|
||||
node perf/progress-tail.mjs
|
||||
node perf/trust-wait.mjs
|
||||
node perf/nest-nodes.mjs
|
||||
node perf/provider-ports.mjs
|
||||
node perf/peer-threads.mjs
|
||||
node perf/text-size.mjs
|
||||
node perf/fs-scale.mjs
|
||||
@@ -356,7 +372,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/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
|
||||
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-linux-appimage|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
|
||||
@@ -600,6 +616,10 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
|
||||
|
||||
- name: The AppImage carries its architecture, the kernel and the page
|
||||
if: runner.os == 'Linux'
|
||||
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
|
||||
|
||||
# Recorded, not asserted: the real bundle layout, so later work argues
|
||||
# from what electron-builder produced rather than from its documentation.
|
||||
- name: Record the macOS bundle layout
|
||||
|
||||
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -87,6 +87,13 @@ lint-cross:
|
||||
test: studio-test sdk-test
|
||||
go test ./...
|
||||
|
||||
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
|
||||
# message it writes to the schema that SDK ships. Needs node and network for
|
||||
# `npm ci`, which runs without install scripts against a lockfile.
|
||||
acp-conformance: build
|
||||
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
|
||||
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
|
||||
|
||||
# The shell is a nested module, so the root `test` target never reaches it.
|
||||
studio-test:
|
||||
cd desktop && go test ./...
|
||||
|
||||
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
|
||||
|
||||
All four are the same program underneath. What follows is for advanced users and contributors.
|
||||
|
||||
## Use it from DeepSeek Harness
|
||||
|
||||
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
|
||||
|
||||
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
|
||||
2. Install "Reasonix subagent" from it.
|
||||
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
|
||||
|
||||
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
|
||||
|
||||
## Versions
|
||||
|
||||
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
|
||||
@@ -151,7 +161,7 @@ pre-releases):
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
|
||||
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
|
||||
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
|
||||
|
||||
Every package ships with a `.minisig` signature, and the release carries
|
||||
`SHA256SUMS`. Once installed, Studio updates itself in place.
|
||||
@@ -240,12 +250,14 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
## Documentation
|
||||
|
||||
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
|
||||
[Use from DeepSeek Harness](./docs/DSH.md)
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and updates](./docs/RECOVERY.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
|
||||
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
|
||||
@@ -277,10 +289,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**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) | [**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) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**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) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+17
-6
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
|
||||
|
||||
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
|
||||
|
||||
## 在 DeepSeek Harness 里使用
|
||||
|
||||
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
|
||||
|
||||
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
|
||||
2. 从中安装“Reasonix subagent”。
|
||||
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
|
||||
|
||||
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
|
||||
|
||||
## 版本
|
||||
|
||||
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
|
||||
@@ -139,7 +149,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
| --- | --- | --- |
|
||||
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
|
||||
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
|
||||
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
|
||||
|
||||
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
|
||||
应用内自行更新。
|
||||
@@ -221,12 +231,13 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
|
||||
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
|
||||
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
|
||||
[ACP 编辑器接入](./docs/ACP.md)
|
||||
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
|
||||
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[恢复与安全模式](./docs/RECOVERY.md) ·
|
||||
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
|
||||
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
|
||||
- **工程与迁移:** [规格](./docs/SPEC.md) ·
|
||||
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
|
||||
[工具合约](./docs/TOOL_CONTRACT.md) ·
|
||||
@@ -259,10 +270,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**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) | [**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) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**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) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
+18
-5
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
- sensitive: internal/platform/gitpush/**
|
||||
- sensitive: internal/session/control/git_push.go
|
||||
- sensitive: internal/frontend/serve/git_push.go
|
||||
- sensitive: internal/frontend/serve/host_routes.go
|
||||
|
||||
One declaration, two effects: the same list is also the coverage gate's subject.
|
||||
`make coverage-gate` holds each path to the coverage it already has, because
|
||||
@@ -409,8 +413,13 @@ moving a number here must not move the promise.
|
||||
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
|
||||
the walk compares. Confining the resolved target is the sandbox's job.
|
||||
- `internal/safety/sandbox` confines writes to the workspace, configured extras, temp
|
||||
and toolchain caches — on macOS and Linux. Windows has no OS-level bash
|
||||
sandbox, so there nothing the host enforces bounds `Writable`.
|
||||
and toolchain caches on macOS and Linux.
|
||||
- On Windows a user-enabled write-restricted token bounds the *creation* of files
|
||||
(create, append, overwrite, `mkdir`, HKCU writes, global git config, writes through links).
|
||||
- There, deleting or renaming away an existing file is not bounded, and with the
|
||||
setting off nothing the host enforces bounds `Writable`.
|
||||
- The caches a command may write can be poisoned for the user's later unsandboxed
|
||||
builds, on every platform.
|
||||
- The governed host authorities are a *named* set — ssh-agent, docker, podman.
|
||||
DBus, Wayland, X11, gpg-agent, keyrings and arbitrary sockets stay reachable,
|
||||
so the claim is "these endpoints are governed", never "host IPC is isolated".
|
||||
@@ -456,9 +465,13 @@ stop a connect. Possession is not authority: the key can be unreadable while an
|
||||
agent still signs with it.
|
||||
|
||||
A protection claim exists only where a backend has a demonstrated enforcement
|
||||
primitive for that dimension. Unsupported dimensions stay explicitly unsupported
|
||||
— Windows currently enforces none of the three — because a claim that outruns
|
||||
its enforcement is worse than an absent one: it is believed.
|
||||
primitive for that dimension. Unsupported dimensions stay explicitly unsupported,
|
||||
because a claim that outruns its enforcement is worse than an absent one: it is
|
||||
believed.
|
||||
|
||||
Windows enforces Integrity on file creation only, when the user turns it on.
|
||||
Deletion and rename, Confidentiality and Authority (the named pipes of ssh-agent
|
||||
and docker, COM and RPC services) are not enforced there.
|
||||
|
||||
**Known unmeasured surface — macOS host-mediated delegation:** Apple Events and
|
||||
Mach-service lookup are not denied by the current allow-default profile. Prior
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Build output and local tooling state.
|
||||
build/
|
||||
.gradle/
|
||||
.kotlin/
|
||||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
|
||||
# The signing key and the file that names it.
|
||||
#
|
||||
# A key is not part of the project: it is the identity one person ships under,
|
||||
# and publishing it would let anyone else sign an APK that installs over
|
||||
# everyone's. Neither the keystore nor its password belongs in version control.
|
||||
# `app/build.gradle` reads both from here when present and falls back to the
|
||||
# debug key when they are not, so a fresh clone builds without them.
|
||||
keystore/
|
||||
|
||||
# Build inputs, produced by scripts/ before Gradle runs.
|
||||
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
|
||||
# frontend. assets/LICENSE is hand-placed and stays tracked.
|
||||
app/src/main/jniLibs/
|
||||
app/src/main/assets/page/
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-10-20
|
||||
---
|
||||
|
||||
# Reasonix Mobile — the Android shell
|
||||
|
||||
Runs Reasonix as an Android app: the kernel ships inside the APK and starts on the
|
||||
device, the interface is the same `desktop/frontend-next` build the desktop Studio
|
||||
serves, and the agent reaches servers over SSH.
|
||||
|
||||
This file is for people building it. What a person installing it needs — what it
|
||||
does, how to pair with a computer, what it cannot do — belongs in the release
|
||||
notes.
|
||||
|
||||
## One command
|
||||
|
||||
```bash
|
||||
./scripts/build.sh release # or debug
|
||||
```
|
||||
|
||||
Three steps, in this order:
|
||||
|
||||
1. `build-kernel.sh` → `app/src/main/jniLibs/arm64-v8a/libreasonix.so`
|
||||
2. `sync-page.sh` → `app/src/main/assets/page/`
|
||||
3. `./gradlew assembleRelease`
|
||||
|
||||
Output: `app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
The kernel and the page are **inputs to packaging, not Gradle build steps**.
|
||||
Running `gradle assembleRelease` on its own fails on purpose and names the script
|
||||
that produces whichever input is missing (`requireBuildInputs` in
|
||||
`app/build.gradle`): an APK with no kernel in it should not be buildable by
|
||||
accident.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| JDK | 21 (17 also works) |
|
||||
| Android SDK | `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` |
|
||||
| Gradle | the committed wrapper, 8.14.3 (for AGP 8.11.1) |
|
||||
| Go | 1.26+ |
|
||||
| Node / pnpm | Node 24+ / pnpm 10 |
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export JAVA_HOME="/path/to/jdk-21"
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
```
|
||||
|
||||
`ANDROID_HOME` is the one thing nothing sets for you.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | What it does |
|
||||
| --- | --- |
|
||||
| `build.sh [debug\|release]` | The whole chain |
|
||||
| `build-kernel.sh` | Cross-compiles the kernel only (`GOOS=android GOARCH=arm64 CGO_ENABLED=0`) |
|
||||
| `sync-page.sh` | Builds the frontend, copies `dist/` into assets, copies `LICENSE` |
|
||||
| `verify-page.sh` | **No phone needed**: starts the real kernel against the packaged page |
|
||||
| `run.sh [debug\|release]` | `adb install`, launch, follow the log |
|
||||
|
||||
`verify-page.sh` is the one to run after a change: it starts the kernel with the
|
||||
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
|
||||
`-page`) and checks that it binds a port and answers 200. Without a device, that
|
||||
is as deep as the chain can be verified.
|
||||
|
||||
## Where the code is
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `MainActivity.kt` | The WebView, the back gesture, downloads, external links, renderer restart |
|
||||
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, waiting for the port, health probes, logs, the workspace directory |
|
||||
| `KernelService.kt` | The foreground service that keeps a long turn alive |
|
||||
| `NativeBridge.kt` | `@JavascriptInterface`: what the page may ask the phone for |
|
||||
| `BrowserShellServer.kt` | Loopback HTTP server the kernel's browser tools drive the overlay WebView through |
|
||||
| `Downloader.kt` | Writing a kernel-produced file through the system document picker |
|
||||
|
||||
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
|
||||
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
|
||||
|
||||
**Phone and desktop share one page build.** What differs only on a phone is
|
||||
decided at runtime, not by a second set of components. `phoneShell()` in
|
||||
`src/port/host.ts` and the `[data-shell="android"]` selector in `mobile.css` both
|
||||
read `<html data-shell="android">`, which `index.html` sets before the bundle
|
||||
loads.
|
||||
|
||||
One attribute feeding both is what keeps style and components from disagreeing
|
||||
about what a phone is.
|
||||
|
||||
## What the page may ask the phone for
|
||||
|
||||
`NativeBridge` is reachable from whatever document the WebView holds, so every
|
||||
method that touches the device asks `trusted()` first.
|
||||
|
||||
The activity answers by comparing the **main frame's** origin against the
|
||||
kernel's and against the one paired computer the person scanned a code for. A
|
||||
page that navigated elsewhere is not trusted, even though it may still hold the
|
||||
object.
|
||||
|
||||
Five methods answer without the check — `version`, `systemTheme`, `safeArea`,
|
||||
`setLayerOpen`, `setRunning` — because they report back what the page already
|
||||
knows about its own window and grant nothing.
|
||||
|
||||
`openInApp(url)` is the pairing decision, not a navigation one: it loads a URL in
|
||||
this window only for the origin the scanner just returned, and only once.
|
||||
Without that, any page holding the kernel origin could hand itself the bridge.
|
||||
|
||||
External links leave through the browser. A non-web scheme is routed to the
|
||||
system only for `mailto:` and `tel:`; everything else is dropped, because an
|
||||
`intent://` URL can start an arbitrary exported activity with caller-supplied
|
||||
extras.
|
||||
|
||||
## Permissions, and what they cost
|
||||
|
||||
`MANAGE_EXTERNAL_STORAGE` — opening a folder on the phone as a workspace. The
|
||||
kernel is a Go binary: it opens real paths with `open(2)`, and SAF hands back a
|
||||
`content://` URI it cannot open.
|
||||
|
||||
Requested from the folder-picker action, never at launch. Android shows no dialog
|
||||
for it and opens system settings instead.
|
||||
|
||||
**There is no OS-level sandbox around this kernel.** The PR sets the sandbox to
|
||||
off on Android, so confining the agent to the chosen folder is Reasonix's own
|
||||
write-root check and nothing else. A shell tool with all-files access can reach
|
||||
all shared storage.
|
||||
|
||||
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — Doze freezes the app group, which stops
|
||||
a turn that is still running. Asked once when a turn actually runs, never at
|
||||
launch, and the person can refuse. Refusing costs battery life during long turns
|
||||
and nothing else.
|
||||
|
||||
**Google Play restricts this permission**: fine for a sideloaded build, a
|
||||
store-policy risk if this is ever listed.
|
||||
|
||||
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_DATA_SYNC` — keeping the kernel alive
|
||||
while the screen is off. `dataSync` is capped per 24 hours from Android 15. It is
|
||||
the closest honest description of "an agent is working".
|
||||
|
||||
`CAMERA` — reading the pairing code a computer shows. Requested when the person
|
||||
asks to scan, never at launch. `ScanActivity` is not exported.
|
||||
|
||||
`READ_EXTERNAL_STORAGE` / `WRITE_EXTERNAL_STORAGE` — the same folder walk below
|
||||
API 33. `maxSdkVersion`-gated; above 33 they are superseded by the permission
|
||||
above.
|
||||
|
||||
`allowBackup=false` with explicit extraction rules, so a `keystore` or a token in
|
||||
app storage never leaves the device in a cloud backup.
|
||||
|
||||
## Cleartext HTTP
|
||||
|
||||
The kernel serves its page on `127.0.0.1:<port>`, and a paired computer serves
|
||||
the same page over its LAN address. Both are plain HTTP.
|
||||
|
||||
Neither address can be written into a `network-security-config`: the port is
|
||||
chosen at start, the LAN address at scan time, and the format matches literals
|
||||
and domains, not subnets.
|
||||
|
||||
So the config permits cleartext and the boundary is in the code instead.
|
||||
`shouldOverrideUrlLoading` sends every other http(s) origin to the browser, and
|
||||
the bridge answers only the kernel and the just-paired computer. A plaintext page
|
||||
on the LAN cannot become the document holding the bridge.
|
||||
|
||||
## What goes into the APK
|
||||
|
||||
| Content | Size | Why it is shaped this way |
|
||||
| --- | --- | --- |
|
||||
| `lib/arm64-v8a/libreasonix.so` | 45 MB | Go static binary, stripped. It is a `.so` rather than a file in `assets/` because only `nativeLibraryDir` is guaranteed executable — a binary copied to app storage fails on a device whose data partition is mounted `noexec`. |
|
||||
| `assets/page/` | 14 MB | The frontend build, including the Inter and Noto Sans SC fonts, `noCompress` |
|
||||
| `assets/LICENSE` | 1 KB | The repository's MIT licence, verbatim. MIT requires the notice and the licence text to accompany a binary distribution. |
|
||||
|
||||
`assets/LICENSE` is generated by `sync-page.sh` from the repository's root
|
||||
`LICENSE`, not committed a second time: a checked-in copy drifts when the root
|
||||
file is updated.
|
||||
|
||||
It sits in `assets/` rather than `assets/page/` because that directory is removed
|
||||
and rebuilt on every build.
|
||||
|
||||
A release APK is about 31 MB.
|
||||
|
||||
## Building
|
||||
|
||||
**The Gradle wrapper is committed**, jar and all. `build.sh` refuses to run
|
||||
without `gradlew` rather than generating one from whatever `gradle` is on `PATH`:
|
||||
the dependency versions are part of the build.
|
||||
|
||||
`gradle-wrapper.properties` pins `distributionSha256Sum`, so a mirror that serves
|
||||
a different distribution zip fails verification instead of being unpacked and
|
||||
run. Dependency repositories are the official ones only — a mirror belongs in a
|
||||
user-level init script.
|
||||
|
||||
**Signing.** A release with no key configured comes out **unsigned**
|
||||
(`app-release-unsigned.apk`) with a warning. It used to fall back to the debug
|
||||
key, which produced an artifact that looked shippable and was signed by a
|
||||
per-machine key nobody chose: it could not install over the previous build.
|
||||
|
||||
Point the build at a real key by writing `keystore/keystore.properties`
|
||||
(`storeFile`, `storePassword`, `keyAlias`, and optionally `keyPassword`) or by
|
||||
setting the matching `REASONIX_KEYSTORE*` environment variables. Key material
|
||||
stays out of the repository and out of build logs.
|
||||
|
||||
Debug builds always use the debug key. Signing a debug build with the release key
|
||||
would let anyone who can read the keystore produce a build that installs over the
|
||||
shipped one under the same signature.
|
||||
|
||||
**Non-ASCII checkout paths.** AGP refuses to build under a path with non-ASCII
|
||||
characters on Windows unless `android.overridePathCheck=true` is set. That is a
|
||||
property of one machine's disk layout, not of this project, so it is not in the
|
||||
repository's `gradle.properties`.
|
||||
|
||||
Set it in a user-level `~/.gradle/gradle.properties` or pass
|
||||
`-Pandroid.overridePathCheck=true`.
|
||||
@@ -0,0 +1,242 @@
|
||||
// The app module.
|
||||
//
|
||||
// Two things ship inside the APK and neither is fetched at runtime: the kernel
|
||||
// binary, and the built page it serves. Bundling the page is what makes the app
|
||||
// work offline and start instantly; bundling the kernel is what lets it run
|
||||
// `reasonix serve` on the phone itself, which is the whole point — the agent
|
||||
// starts locally and reaches the server over SSH, so the interface, the model
|
||||
// catalogue and the credentials all live on the phone.
|
||||
//
|
||||
// The kernel is placed as `jniLibs/arm64-v8a/libreasonix.so`, not under
|
||||
// `assets/`. That is not a trick for its own sake: Android permits executing a
|
||||
// file under `nativeLibraryDir` and nothing under `filesDir` has that
|
||||
// guarantee, so a binary shipped the ordinary way would have to be copied out
|
||||
// and made executable at runtime — which fails outright on a device mounted
|
||||
// noexec, and fails confusingly on the ones that are merely unusual. `scripts/
|
||||
// build-kernel.sh` writes it there; the name is fixed by the packaging rules.
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "org.jetbrains.kotlin.android"
|
||||
}
|
||||
|
||||
// One version, read by the About screen and the update check.
|
||||
def versionMajor = 2
|
||||
def versionMinor = 0
|
||||
def versionPatch = 0
|
||||
def versionBuild = 1
|
||||
|
||||
// Where the two build inputs are expected. Both are produced from the checkout
|
||||
// beside this module, by the scripts named in each message.
|
||||
def upstream = rootProject.file("..")
|
||||
def pageDir = new File(projectDir, "src/main/assets/page")
|
||||
def kernelSo = new File(projectDir, "src/main/jniLibs/arm64-v8a/libreasonix.so")
|
||||
|
||||
android {
|
||||
namespace "io.reasonix.mobile.app"
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "io.reasonix.mobile.app"
|
||||
minSdk 26
|
||||
// 35: the API level this app is actually built and tested against.
|
||||
//
|
||||
// It was briefly lowered to 26 while chasing an install failure that
|
||||
// turned out to be a package-name collision with an unrelated app on the
|
||||
// user's phone — the signature error named the wrong cause. Nothing about
|
||||
// the target level ever caused it, so it goes back to the level the code
|
||||
// is really written for.
|
||||
//
|
||||
// One consequence to keep in mind: from 35, Android 15 forces the window
|
||||
// edge-to-edge and ignores a request to lay out inside the safe area. The
|
||||
// window therefore *is* drawn under the status bar and the home indicator,
|
||||
// and the only correct answer is for the page to pad itself — which is
|
||||
// what `MainActivity` feeds real inset values for, since a WebView does
|
||||
// not implement `env(safe-area-inset-*)`.
|
||||
targetSdk 35
|
||||
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
|
||||
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
|
||||
|
||||
// The kernel is built for arm64 only, so every other ABI would ship an
|
||||
// APK that installs and then cannot start. Naming the one real ABI keeps
|
||||
// the store listing and the installer honest.
|
||||
ndk {
|
||||
abiFilters "arm64-v8a"
|
||||
}
|
||||
|
||||
resourceConfigurations += ["zh", "en"]
|
||||
}
|
||||
|
||||
// Android identifies an app by package name *and* signing key: an APK signed
|
||||
// with a different key is not an update, it is an impostor, and the
|
||||
// installer refuses it with "the signature does not match the installed
|
||||
// app". Gradle's default release key is the debug key in the user's home
|
||||
// directory, which is per-machine, so a build made on a second computer
|
||||
// could never be installed over one made on the first.
|
||||
//
|
||||
// Whoever ships a build therefore pins a key — and that key is theirs, not
|
||||
// the project's. It lives beside the checkout, in `keystore/`, which is
|
||||
// ignored; so does the file naming it. A checkout without either signs with
|
||||
// the debug key instead, which is what a contributor building locally wants
|
||||
// and is what the README tells them to expect. Point the build at a real key
|
||||
// by writing `keystore/keystore.properties` (storeFile, storePassword,
|
||||
// keyAlias) or by setting REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD /
|
||||
// REASONIX_KEY_ALIAS.
|
||||
def keyProperties = new Properties()
|
||||
def keyPropertiesFile = rootProject.file("keystore/keystore.properties")
|
||||
if (keyPropertiesFile.exists()) {
|
||||
keyPropertiesFile.withInputStream { keyProperties.load(it) }
|
||||
}
|
||||
def keyStorePath = keyProperties.getProperty("storeFile") ?: System.getenv("REASONIX_KEYSTORE")
|
||||
def keyStorePassword = keyProperties.getProperty("storePassword") ?: System.getenv("REASONIX_KEYSTORE_PASSWORD")
|
||||
// The key's own password is a separate fact from the store's. PKCS#12 keystores
|
||||
// almost always use one password for both, so falling back to the store's
|
||||
// password keeps those working — but assuming they are equal is how a keystore
|
||||
// that separates them gets a build failure at signing time, after the work is
|
||||
// done, with an error that does not say which of the two was wrong.
|
||||
def keyPasswordValue = keyProperties.getProperty("keyPassword") ?: System.getenv("REASONIX_KEY_PASSWORD") ?: keyStorePassword
|
||||
def keyAliasName = keyProperties.getProperty("keyAlias") ?: System.getenv("REASONIX_KEY_ALIAS")
|
||||
def haveReleaseKey = keyStorePath && keyStorePassword && keyAliasName &&
|
||||
rootProject.file(keyStorePath).exists()
|
||||
|
||||
signingConfigs {
|
||||
if (haveReleaseKey) {
|
||||
releaseKey {
|
||||
storeFile rootProject.file(keyStorePath)
|
||||
storePassword keyStorePassword
|
||||
keyAlias keyAliasName
|
||||
keyPassword keyPasswordValue
|
||||
// v1 is not optional here. Android 7+ reads v2 and ignores v1, so
|
||||
// a v2-only APK installs on a modern phone — and then fails on the
|
||||
// compatibility layers older devices and HarmonyOS' 卓易通 run,
|
||||
// which know only the JAR signature and report its absence as a
|
||||
// signature *mismatch*. Signing both is what makes one APK install
|
||||
// everywhere.
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing native is compiled here — the one .so is a prebuilt the kernel
|
||||
// script writes — so the external native build is left alone entirely.
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-debug"
|
||||
// The debug key, always. Signing a debug build with the release key
|
||||
// would mean anyone who can read the release keystore can produce a
|
||||
// build that installs over the shipped one under the same signature,
|
||||
// and it buys nothing: the debug package is suffixed `.debug`, so the
|
||||
// two already install side by side.
|
||||
}
|
||||
release {
|
||||
// The kernel is a stripped Go binary and the page is minified, but
|
||||
// the Kotlin shell still benefits and shipping the shrunk build is
|
||||
// how a size regression gets noticed.
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
// A release with no key of its own comes out *unsigned*
|
||||
// (`app-release-unsigned.apk`). It used to fall back to the debug key
|
||||
// so the artifact would install, which produced a release that looked
|
||||
// shippable and was signed by a per-machine key nobody chose: it could
|
||||
// not be installed over the previous build, and every machine made a
|
||||
// different one. An unsigned artifact says what it is.
|
||||
if (haveReleaseKey) {
|
||||
signingConfig signingConfigs.releaseKey
|
||||
} else {
|
||||
logger.warn(
|
||||
"release build has no signing key: producing an unsigned APK. " +
|
||||
"Write keystore/keystore.properties (storeFile, storePassword, keyAlias) " +
|
||||
"or set REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD / REASONIX_KEY_ALIAS."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
// AGP 8 stops generating BuildConfig unless asked. `Kernel` reads the
|
||||
// version name and code out of it to decide whether the bundled page
|
||||
// needs unpacking, so it is asked for.
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// `extractNativeLibs=true`: the kernel must exist as a real file on
|
||||
// disk to be executed, and an uncompressed-and-mmap'd .so is not
|
||||
// one. This is the only reason the flag is on.
|
||||
useLegacyPackaging true
|
||||
}
|
||||
resources {
|
||||
excludes += ["META-INF/*.version", "META-INF/*.kotlin_module", "kotlin/**", "DebugProbesKt.bin"]
|
||||
}
|
||||
}
|
||||
|
||||
// The page is read out of assets by `Kernel.publish`, not by the resource
|
||||
// system, so it is not compressed — a page is already-compressed text and
|
||||
// shrinking it again only costs startup time.
|
||||
androidResources {
|
||||
noCompress += ["json", "woff2", "ttf", "woff", "txt", "svg"]
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError true
|
||||
warningsAsErrors false
|
||||
checkReleaseBuilds true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.core:core-ktx:1.15.0"
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||
implementation "androidx.webkit:webkit:1.12.1"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
|
||||
|
||||
// Scanning the pairing code a computer shows.
|
||||
//
|
||||
// CameraX for the preview and the frame stream, ZXing for the decode, and
|
||||
// deliberately not ML Kit: this app installs on phones whose HarmonyOS
|
||||
// compatibility layer has no Google Play Services, so a decoder that needs
|
||||
// GMS would work on the developer's phone and fail on the user's. ZXing
|
||||
// core is pure Java with no runtime dependency at all, which is what makes
|
||||
// the scanner work on both.
|
||||
implementation "androidx.camera:camera-core:1.4.1"
|
||||
implementation "androidx.camera:camera-camera2:1.4.1"
|
||||
implementation "androidx.camera:camera-lifecycle:1.4.1"
|
||||
implementation "androidx.camera:camera-view:1.4.1"
|
||||
implementation "com.google.zxing:core:3.5.3"
|
||||
}
|
||||
|
||||
// Fail before the packaging step with something a person can act on, rather
|
||||
// than shipping an APK whose kernel is missing or whose page is a directory the
|
||||
// build never filled. The two messages name the script that produces each.
|
||||
tasks.register("requireBuildInputs") {
|
||||
doFirst {
|
||||
if (!kernelSo.exists()) {
|
||||
throw new GradleException(
|
||||
"内核二进制缺失:${kernelSo}\n先运行 ./scripts/build-kernel.sh(在 android/ 目录下)交叉编译内核。")
|
||||
}
|
||||
if (!pageDir.exists() || !new File(pageDir, "index.html").exists()) {
|
||||
throw new GradleException(
|
||||
"前端页面缺失:${pageDir}/index.html\n先运行 ./scripts/sync-page.sh(在 android/ 目录下)构建并同步页面。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.configureEach { task ->
|
||||
if (task.name.startsWith("merge") && (task.name.contains("Assets") || task.name.contains("JniLibFolders"))) {
|
||||
task.dependsOn("requireBuildInputs")
|
||||
}
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# R8 rules for the shell.
|
||||
#
|
||||
# There is very little to keep here, and that is the point: the app's own code
|
||||
# is reached from the manifest and from Android's own callbacks, both of which
|
||||
# R8 already traces, and nothing in it is loaded reflectively. What follows is
|
||||
# the two places where a name is looked up rather than called.
|
||||
|
||||
# The WebView's JavaScript bridge, if one is ever added, is resolved by name on
|
||||
# the Java side and by the string the page uses. Keeping annotated members makes
|
||||
# that survive shrinking rather than failing at runtime with a method-not-found
|
||||
# that only shows up on a release build.
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# The Kernel's child process is started by absolute path, and its own log is
|
||||
# read as text; neither is reflective, so nothing else needs keeping.
|
||||
|
||||
# Line numbers make a stack trace from a release build readable, which is the
|
||||
# only way a crash report from a phone can be acted on.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
# Kotlin's `@Metadata` annotation is present on every class the compiler touched
|
||||
# and nothing in this app reflects over it. It is tempting to `-assumenosideeffects`
|
||||
# it away, and that was tried: any pattern broad enough to name its members also
|
||||
# matches the methods every class inherits from `Object` — `equals`, `hashCode`,
|
||||
# `toString` — and R8 then treats those as removable, which is a silent
|
||||
# behaviour change for a few kilobytes. Left as a warning suppression instead:
|
||||
# the annotation is dropped from the shrunk output anyway, because nothing reads
|
||||
# it.
|
||||
-dontwarn kotlin.Metadata
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The shell's manifest.
|
||||
|
||||
There is no INTERNET-adjacent permission beyond INTERNET itself: the page and
|
||||
the kernel talk over loopback, and reaching a server is the kernel's job over
|
||||
a socket it owns. Reading and writing files goes through the system's document
|
||||
picker (SAF), which hands back a grant for one file at a time — so no
|
||||
storage permission is declared and none is needed.
|
||||
|
||||
`usesCleartextTraffic` is off here, and cleartext is re-enabled in the network
|
||||
security config only because the kernel serves its page on `127.0.0.1` and a
|
||||
paired computer serves the same page over its LAN address — both plain HTTP.
|
||||
The config says what that costs and what bounds it.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Only to tell the person the kernel is still running while the screen is
|
||||
off; it is a foreground service with a dismissible notification, not a
|
||||
background wake-up. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- From API 34 every foreground-service *type* needs its own permission as
|
||||
well as the general one: naming dataSync in the manifest without asking
|
||||
for it is a SecurityException at startForeground, on the one code path
|
||||
that keeps a long task alive. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Only to read the pairing code a computer shows. Requested at the
|
||||
moment the person asks to scan, never at launch. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- The kernel is a child process, and a task left running is one the person
|
||||
expects to finish with the screen off. Android's own doze can freeze the
|
||||
whole app group instead, which is what the shell asks to be exempt from
|
||||
— once, when a turn is actually running, never at launch. It is a
|
||||
request the person can refuse, and refusing it only costs battery life
|
||||
during a long turn. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<!-- Every-file access, for opening a folder on this phone as a workspace.
|
||||
The kernel is a plain Go binary: it opens paths with `open(2)`, and a
|
||||
document URI is not a path it can open. SAF would hand back exactly
|
||||
that unusable URI, so the one honest permission is the broad one —
|
||||
granted by the person in system settings, which is the only place
|
||||
Android offers it. Nothing here reads storage for any other purpose. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<!-- Below API 33 the same folder walk needs the older pair; from 33 they are
|
||||
superseded by the permission above and asking for them would be a lie. -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
|
||||
<!-- A phone without a touchscreen cannot drive this interface; saying so
|
||||
keeps the listing honest rather than letting it install on a TV. -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
|
||||
<!-- The camera is optional: a phone without one is still a fine client for
|
||||
a kernel it already knows, and it can still pair by opening the link the
|
||||
computer shows. Requiring it would take the app off those devices. -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Reasonix"
|
||||
android:usesCleartextTraffic="false"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|smallestScreenSize"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Reasonix">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The kernel outlives the screen: a turn that takes minutes would
|
||||
otherwise be killed when the phone is pocketed. The service is
|
||||
`stopWithTask="false"` so swiping the app away ends it deliberately
|
||||
rather than silently, and `foregroundServiceType="dataSync"` is the
|
||||
closest honest description of "an agent is working". -->
|
||||
<service
|
||||
android:name=".KernelService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- Reading the pairing code a computer shows. Its own activity rather
|
||||
than a dialog, because the camera preview needs the whole screen to
|
||||
be worth pointing at anything. Not exported: only this app starts
|
||||
it, and a scanner another app could open is one that could be made
|
||||
to read a code on someone else's behalf. -->
|
||||
<activity
|
||||
android:name=".ScanActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Reasonix.Scan" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The process-wide facts this app needs.
|
||||
*
|
||||
* The kernel is one child process, so there is exactly one of it no matter how
|
||||
* many activities exist: it is held here, lazily, and the activity and the
|
||||
* service both reach the same instance. Nothing else is stored — the theme, the
|
||||
* language and every preference belong to the page and live in its localStorage.
|
||||
*/
|
||||
class App : Application() {
|
||||
|
||||
/**
|
||||
* The kernel, created on first use.
|
||||
*
|
||||
* Lazy rather than eager so that merely launching the process to answer a
|
||||
* notification does not unpack a 15MB page.
|
||||
*/
|
||||
val kernel: Kernel by lazy { Kernel(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel the person can mute without muting the app, and one they can
|
||||
* find: an agent that runs for minutes on a phone has to say so somewhere
|
||||
* the system will not kill, and a notification is the only place Android
|
||||
* offers. `IMPORTANCE_MIN` because the notification is a receipt, not an
|
||||
* interruption — the work it reports is on another machine.
|
||||
*/
|
||||
private fun createChannel() {
|
||||
// No SDK_INT guard: minSdk is 26, which is the release NotificationChannel
|
||||
// arrived in, so every device this app installs on has it.
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_KERNEL) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_KERNEL,
|
||||
getString(R.string.channel_kernel),
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply {
|
||||
description = getString(R.string.channel_kernel_desc)
|
||||
setShowBadge(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_KERNEL = "reasonix-kernel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The kernel's browser, as a loopback HTTP server.
|
||||
*
|
||||
* The Go kernel (`internal/platform/browser/shell.go`) drives a WebView by
|
||||
* POSTing JSON to a handful of paths on 127.0.0.1. This class is the other end
|
||||
* of that conversation: it accepts connections on a random port, parses the
|
||||
* minimal HTTP the kernel sends, and translates each path into an operation on
|
||||
* the overlay WebView.
|
||||
*
|
||||
* The server is deliberately tiny — no connection pooling, no keep-alive, no
|
||||
* chunked encoding — because the kernel is its only client and sends one
|
||||
* request per connection. A `ServerSocket` accept loop on a daemon thread is
|
||||
* the whole transport; each request is handled synchronously on its own thread.
|
||||
*
|
||||
* WebView operations must run on the UI thread. Each handler posts to the main
|
||||
* looper and waits on a latch, with a timeout that bounds the worst case (a
|
||||
* page that hangs the JS thread) to something the kernel's own 30-second client
|
||||
* timeout will not outlast.
|
||||
*
|
||||
* Memory note on `/screenshot`: a full-page `WebView.draw` into an ARGB_8888
|
||||
* bitmap at device resolution can reach tens of MB for a long page. The bitmap
|
||||
* is downsampled by a factor of 2 in each dimension before PNG encoding, which
|
||||
* keeps the peak allocation under ~10 MB for a typical 1080p viewport. The
|
||||
* original is recycled immediately after the downsample.
|
||||
*/
|
||||
/** One thing a page asked the network for, as the WebView's request
|
||||
* interceptor reports it. The interceptor is handed the method and the URL
|
||||
* and nothing about the response, so a caller may show exactly this much and
|
||||
* must not read it as a completed exchange. */
|
||||
data class BrowserRequest(val method: String, val url: String, val kind: String)
|
||||
|
||||
class BrowserShellServer(
|
||||
/** The overlay WebView, when one already exists. The routes that act on an
|
||||
* open page — back, forward, reload, close — use this: with no page there
|
||||
* is nothing to act on, and creating one to do nothing to it would start a
|
||||
* renderer nobody asked for. */
|
||||
private val existingView: () -> WebView?,
|
||||
/** The overlay WebView, created on first use. The routes that drive a page
|
||||
* use this, because the kernel can open one before the panel has ever been
|
||||
* shown: the page's own panel asks for the view when it is displayed, and
|
||||
* an agent's `browser_open` can arrive first. Requiring the view to exist
|
||||
* already answered every such call with "browser view not ready", which
|
||||
* reads as a broken engine rather than as a view that was not created yet.
|
||||
* Called on the UI thread. */
|
||||
private val ensureView: () -> WebView,
|
||||
/** Why the page in the view is the browser's own error page rather than a
|
||||
* site, as `(url, chromium error)`; null when the last load succeeded.
|
||||
* `/read` asks this, because extracting `innerText` from the error page
|
||||
* returns the browser's own "this page could not be loaded" notice, which
|
||||
* the kernel would hand to the model as though the site had said it. */
|
||||
private val loadFailure: () -> Pair<String, String>? = { null },
|
||||
/** Begin a navigation: forget the load failure recorded for the page being
|
||||
* left, drop its request list, and start the wait a following `/read`
|
||||
* joins. Called on the UI thread, before the new address is loaded, so the
|
||||
* wait is in place before any callback for the new page can fire. */
|
||||
private val startNavigation: () -> Unit = {},
|
||||
/** What the page currently in the view has asked the network for, oldest
|
||||
* first. The WebView reports a request's method and URL to its interceptor
|
||||
* but nothing about the response, so that is all a caller may show. */
|
||||
private val requests: () -> List<BrowserRequest> = { emptyList() },
|
||||
/** Wait for the navigation last asked for to settle — the page arrived, or
|
||||
* its load failed — up to the given milliseconds. Called on the request's
|
||||
* own thread, never the UI thread: the settle is signalled from the UI
|
||||
* thread, so waiting on it there would deadlock. `/load` starts a
|
||||
* navigation and returns before the page has been visited; without this a
|
||||
* `/read` right behind it still sees the URL of the page before, and a
|
||||
* failure recorded for that page was handed back as the new one's answer. */
|
||||
private val awaitNavigation: (Long) -> Unit = {},
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BrowserShellServer"
|
||||
|
||||
/** How long a UI-thread operation may take before the handler gives up.
|
||||
* The kernel's client timeout is 30 s; staying under it means the kernel
|
||||
* sees a clean error rather than a dropped connection. */
|
||||
private const val UI_TIMEOUT_MS = 25_000L
|
||||
|
||||
/** The maximum text `/read` returns. Matches the kernel's `maxReadText`
|
||||
* (400 << 10). The shell truncates here and says so, rather than letting
|
||||
* the kernel truncate silently. */
|
||||
private const val MAX_READ_TEXT = 400 * 1024
|
||||
|
||||
/** Screenshot downsample factor. 2 means half width and half height. */
|
||||
private const val SCREENSHOT_SCALE = 2
|
||||
|
||||
/** The address the kernel dials. Both ends must agree on the family:
|
||||
* see [start] for what goes wrong when they do not. */
|
||||
private const val LOOPBACK_V4 = "127.0.0.1"
|
||||
|
||||
/** How long `/read` waits for a navigation it follows to settle before
|
||||
* reading whatever the view is on. A page that never finishes loading
|
||||
* must not hold the tool call open to the kernel's own timeout. */
|
||||
private const val NAV_SETTLE_MS = 10_000L
|
||||
|
||||
/** How long `/read` then waits for the page's own script to answer.
|
||||
* `NAV_SETTLE_MS + READ_SCRIPT_MS` has to stay under [UI_TIMEOUT_MS],
|
||||
* and that under the kernel's 30-second client timeout: a read that
|
||||
* waited out both budgets would outlast the caller, which would see a
|
||||
* dropped connection instead of this shell's answer. 10 + 15 = 25 s. */
|
||||
private const val READ_SCRIPT_MS = 15_000L
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var serverSocket: ServerSocket? = null
|
||||
|
||||
/** The port the server is listening on. Valid after [start] returns. */
|
||||
@Volatile
|
||||
var port: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* The bearer credential every request must carry. Minted by the activity
|
||||
* and handed to the kernel through the child's environment, so the server
|
||||
* answers only the process this shell started.
|
||||
*
|
||||
* A phone's loopback is not a boundary: any other app may connect to
|
||||
* 127.0.0.1, and the routes here run script in and read text out of
|
||||
* whatever page is loaded. Binding to `getLoopbackAddress()` keeps other
|
||||
* machines out but not other apps, so the credential is what makes "only
|
||||
* the kernel this shell started" true.
|
||||
*/
|
||||
@Volatile
|
||||
var token: String = ""
|
||||
private set
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Start listening on 127.0.0.1 with an ephemeral port. The port is available
|
||||
* via [port] immediately after this returns, and the credential the kernel
|
||||
* must present via [token].
|
||||
*/
|
||||
fun start() {
|
||||
token = newToken()
|
||||
// The loopback address is spelled out as IPv4, not taken from
|
||||
// `InetAddress.getLoopbackAddress()`. That call is allowed to answer
|
||||
// either family and Android's libcore answers IPv6 (`::1`), while the
|
||||
// kernel's client is hardcoded to `http://127.0.0.1:<port>`
|
||||
// (`internal/assembly/boot/browser.go`). A socket bound to `::1` does
|
||||
// not answer a dial to `127.0.0.1`, so every browser call failed with
|
||||
// "connection refused" while the port was bound and reported — the
|
||||
// failure looked like a server that never started. Both ends have to
|
||||
// name the same family, and IPv4 is the one the kernel names.
|
||||
val ss = ServerSocket(0, 16, java.net.InetAddress.getByName(LOOPBACK_V4))
|
||||
serverSocket = ss
|
||||
port = ss.localPort
|
||||
Log.i(TAG, "browser shell server listening on $LOOPBACK_V4:$port")
|
||||
|
||||
Thread({
|
||||
while (!ss.isClosed) {
|
||||
try {
|
||||
val client = ss.accept()
|
||||
Thread({ handleClient(client, token) }, "browser-shell-conn").apply { isDaemon = true }.start()
|
||||
} catch (_: SocketException) {
|
||||
// Server was closed; exit the loop.
|
||||
}
|
||||
}
|
||||
}, "browser-shell-accept").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runCatching { serverSocket?.close() }
|
||||
serverSocket = null
|
||||
}
|
||||
|
||||
/** A fresh 256-bit credential, hex-encoded, for one launch of the server. */
|
||||
private fun newToken(): String {
|
||||
val bytes = ByteArray(32)
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
// ---- HTTP plumbing --------------------------------------------------------
|
||||
|
||||
private fun handleClient(client: Socket, expected: String) {
|
||||
try {
|
||||
client.soTimeout = 30_000
|
||||
val input = BufferedInputStream(client.getInputStream())
|
||||
val output = client.getOutputStream()
|
||||
|
||||
// Parse the request line.
|
||||
val requestLine = readLine(input) ?: return
|
||||
val parts = requestLine.split(" ", limit = 3)
|
||||
if (parts.size < 2) return
|
||||
val method = parts[0]
|
||||
val path = parts[1].substringBefore('?')
|
||||
|
||||
// Read headers.
|
||||
var contentLength = 0
|
||||
var bearer: String? = null
|
||||
while (true) {
|
||||
val header = readLine(input) ?: break
|
||||
if (header.isEmpty()) break
|
||||
val lower = header.lowercase()
|
||||
if (lower.startsWith("content-length:")) {
|
||||
contentLength = header.substringAfter(':').trim().toIntOrNull() ?: 0
|
||||
} else if (lower.startsWith("authorization:")) {
|
||||
bearer = header.substringAfter(':').trim().removePrefix("Bearer ").trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing behind this server is safe to hand out on the strength of
|
||||
// an address: the port is on loopback, which any other app on the
|
||||
// phone can dial, and the routes run script in and read text out of
|
||||
// the loaded page. The credential the kernel was started with is the
|
||||
// only thing that says a caller is that kernel. Every route is
|
||||
// refused without it, /health included — a probe needs no exemption.
|
||||
if (expected.isEmpty() || bearer != expected) {
|
||||
writeResponse(output, 401, """{"error":"unauthorized"}""")
|
||||
return
|
||||
}
|
||||
|
||||
// Read body.
|
||||
val body = if (contentLength > 0) {
|
||||
val buf = ByteArray(contentLength)
|
||||
var read = 0
|
||||
while (read < contentLength) {
|
||||
val n = input.read(buf, read, contentLength - read)
|
||||
if (n == -1) break
|
||||
read += n
|
||||
}
|
||||
String(buf, 0, read, Charsets.UTF_8)
|
||||
} else ""
|
||||
|
||||
// Route.
|
||||
val (status, responseBody) = route(method, path, body)
|
||||
writeResponse(output, status, responseBody)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "connection error: ${e.message}")
|
||||
} finally {
|
||||
runCatching { client.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun route(method: String, path: String, body: String): Pair<Int, String> {
|
||||
return when {
|
||||
method == "GET" && path == "/health" -> 200 to """{"ok":true}"""
|
||||
method == "POST" && path == "/load" -> handleLoad(body)
|
||||
method == "POST" && path == "/close" -> handleClose()
|
||||
method == "POST" && path == "/read" -> handleRead()
|
||||
method == "POST" && path == "/screenshot" -> handleScreenshot()
|
||||
method == "POST" && path == "/click" -> handleClick(body)
|
||||
method == "POST" && path == "/eval" -> handleEval(body)
|
||||
method == "POST" && path == "/back" -> handleBack()
|
||||
method == "POST" && path == "/forward" -> handleForward()
|
||||
method == "POST" && path == "/reload" -> handleReload()
|
||||
method == "POST" && path == "/requests" -> handleRequests()
|
||||
else -> 404 to """{"error":"not found"}"""
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeResponse(out: OutputStream, status: Int, body: String) {
|
||||
val reason = when (status) {
|
||||
200 -> "OK"
|
||||
404 -> "Not Found"
|
||||
500 -> "Internal Server Error"
|
||||
else -> "Error"
|
||||
}
|
||||
val bytes = body.toByteArray(Charsets.UTF_8)
|
||||
val header = "HTTP/1.1 $status $reason\r\n" +
|
||||
"Content-Type: application/json\r\n" +
|
||||
"Content-Length: ${bytes.size}\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"\r\n"
|
||||
out.write(header.toByteArray(Charsets.UTF_8))
|
||||
out.write(bytes)
|
||||
out.flush()
|
||||
}
|
||||
|
||||
private fun readLine(input: BufferedInputStream): String? {
|
||||
val sb = StringBuilder()
|
||||
while (true) {
|
||||
val b = input.read()
|
||||
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
|
||||
if (b == '\r'.code) {
|
||||
val next = input.read()
|
||||
if (next == '\n'.code) return sb.toString()
|
||||
sb.append(b.toChar())
|
||||
if (next != -1) sb.append(next.toChar())
|
||||
continue
|
||||
}
|
||||
if (b == '\n'.code) return sb.toString()
|
||||
sb.append(b.toChar())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- endpoint handlers ----------------------------------------------------
|
||||
|
||||
/** POST /load {"url":"..."} → {"url":"...","title":"..."} */
|
||||
private fun handleLoad(body: String): Pair<Int, String> {
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
?: return 500 to """{"error":"invalid request body"}"""
|
||||
val url = json.optString("url", "")
|
||||
if (url.isEmpty()) return 500 to """{"error":"missing url"}"""
|
||||
|
||||
val result = AtomicReference("")
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = ensureView()
|
||||
// The failure recorded for the page being left stops being this
|
||||
// tab's, and the wait a following `/read` joins is armed before the
|
||||
// new address is loaded, so no callback for the new page can fire
|
||||
// ahead of it. `/load` answers before the page arrives, which is
|
||||
// what left the old failure to be read back as the new page's.
|
||||
startNavigation()
|
||||
wv.loadUrl(url)
|
||||
// The title and final URL are not available synchronously after
|
||||
// loadUrl; answer with what was requested. The kernel stores this
|
||||
// as the tab's URL and reads the real one on the next /read.
|
||||
result.set(JSONObject().apply {
|
||||
put("url", url)
|
||||
put("title", "")
|
||||
}.toString())
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout waiting for UI thread"}"""
|
||||
}
|
||||
return 200 to result.get()
|
||||
}
|
||||
|
||||
/** POST /close {} → 200 */
|
||||
private fun handleClose(): Pair<Int, String> {
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = existingView()
|
||||
if (wv != null) {
|
||||
wv.loadUrl("about:blank")
|
||||
wv.clearHistory()
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
return 200 to "{}"
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /read {} → {"title":"...","url":"...","text":"..."}
|
||||
*
|
||||
* Injects a script that extracts readable text from the page, preferring
|
||||
* `<article>` or `<main>` and falling back to `body.innerText`. The result
|
||||
* is truncated to 400 KB (matching the kernel's `maxReadText`); when
|
||||
* truncated, a note is appended so the model knows the text was cut.
|
||||
*/
|
||||
private fun handleRead(): Pair<Int, String> {
|
||||
// The page the caller just asked for has not necessarily been visited
|
||||
// yet: `/load` returns as soon as it has started the navigation. Waiting
|
||||
// here, off the UI thread, is what makes this read answer about that
|
||||
// page instead of the one before it.
|
||||
awaitNavigation(NAV_SETTLE_MS)
|
||||
val result = AtomicReference("")
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = ensureView()
|
||||
// A page that did not load is not content. Whether the view is on
|
||||
// the browser's own error page is read from the view itself rather
|
||||
// than from a remembered failure: a failure recorded for the page
|
||||
// before a new navigation would otherwise be handed back as this
|
||||
// page's answer, long after the page the caller asked for had
|
||||
// loaded. The remembered failure is still what names the address
|
||||
// that failed and why, since the error page's own URL does not.
|
||||
if ((wv.url ?: "").startsWith("chrome-error:")) {
|
||||
val (url, reason) = loadFailure() ?: ((wv.url ?: "") to "ERR_FAILED")
|
||||
result.set(JSONObject().apply {
|
||||
put("url", url)
|
||||
put("title", "")
|
||||
put("reason", reason)
|
||||
put("text", "")
|
||||
}.toString())
|
||||
latch.countDown()
|
||||
return@post
|
||||
}
|
||||
// The extraction script: prefer article/main for content pages,
|
||||
// fall back to body. innerText preserves visual line breaks. It
|
||||
// also stamps a ref on every element a person could act on, so the
|
||||
// kernel can name one in an act step the way the desktop backend
|
||||
// names an accessibility node. A ref already on an element is kept,
|
||||
// so it stays the same across snapshots of one document; a fresh
|
||||
// document starts the numbering over, and the kernel only accepts a
|
||||
// ref from the snapshot it last read.
|
||||
val script = """
|
||||
(function(){
|
||||
var seq = window.__rxRefSeq || 0;
|
||||
var used = {};
|
||||
var marked = document.querySelectorAll('[data-rx-ref]');
|
||||
for (var i = 0; i < marked.length; i++) { used[marked[i].getAttribute('data-rx-ref')] = true; }
|
||||
function roleOf(el) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var role = (el.getAttribute('role') || '').toLowerCase();
|
||||
if (role) return role;
|
||||
if (tag === 'a') return 'link';
|
||||
if (tag === 'button') return 'button';
|
||||
if (tag === 'select') return 'combobox';
|
||||
if (tag === 'textarea') return 'textbox';
|
||||
if (tag === 'input') {
|
||||
var t = (el.getAttribute('type') || 'text').toLowerCase();
|
||||
if (t === 'checkbox' || t === 'radio' || t === 'submit' || t === 'button' || t === 'reset') return t;
|
||||
return 'textbox';
|
||||
}
|
||||
return 'generic';
|
||||
}
|
||||
function nameOf(el) {
|
||||
var n = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '';
|
||||
if (!n) { n = (el.innerText || el.textContent || '').trim(); }
|
||||
return n.replace(/\s+/g, ' ').slice(0, 160);
|
||||
}
|
||||
var sel = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
|
||||
var els = document.querySelectorAll(sel);
|
||||
var elements = [];
|
||||
for (var j = 0; j < els.length; j++) {
|
||||
var el = els[j];
|
||||
var ref = el.getAttribute('data-rx-ref');
|
||||
if (!ref) {
|
||||
do { seq++; ref = 'e' + seq; } while (used[ref]);
|
||||
el.setAttribute('data-rx-ref', ref);
|
||||
used[ref] = true;
|
||||
}
|
||||
var raw = (el.value != null && String(el.value) !== '') ? String(el.value).slice(0, 160) : '';
|
||||
elements.push({ref: ref, role: roleOf(el), name: nameOf(el), value: raw});
|
||||
}
|
||||
window.__rxRefSeq = seq;
|
||||
var el2 = document.querySelector('article') || document.querySelector('main') || document.body;
|
||||
var text = el2 ? (el2.innerText || '') : '';
|
||||
var title = document.title || '';
|
||||
var url = location.href;
|
||||
return JSON.stringify({title: title, url: url, text: text, elements: elements});
|
||||
})()
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(script) { value ->
|
||||
// evaluateJavascript returns a JSON-encoded string (the result
|
||||
// is double-encoded). Strip the outer quotes.
|
||||
val parsed = parseJsResult(value)
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val obj = JSONObject(parsed)
|
||||
var text = obj.optString("text", "")
|
||||
if (text.length > MAX_READ_TEXT) {
|
||||
text = text.substring(0, MAX_READ_TEXT) +
|
||||
"\n\n[text truncated at 400 KB]"
|
||||
}
|
||||
result.set(JSONObject().apply {
|
||||
put("title", obj.optString("title", ""))
|
||||
put("url", obj.optString("url", ""))
|
||||
put("text", text)
|
||||
put("elements", obj.optJSONArray("elements") ?: JSONArray())
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
result.set(JSONObject().apply {
|
||||
put("title", "")
|
||||
put("url", wv.url ?: "")
|
||||
put("text", parsed.take(MAX_READ_TEXT))
|
||||
}.toString())
|
||||
}
|
||||
} else {
|
||||
result.set(JSONObject().apply {
|
||||
put("title", "")
|
||||
put("url", wv.url ?: "")
|
||||
put("text", "")
|
||||
}.toString())
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
if (!latch.await(READ_SCRIPT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout waiting for page script"}"""
|
||||
}
|
||||
return 200 to result.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /screenshot {} → {"data":"<base64>","mime":"image/png"}
|
||||
*
|
||||
* Draws the WebView into a bitmap, downsamples by SCREENSHOT_SCALE to keep
|
||||
* memory bounded, and encodes as PNG → base64. For a 1080×2400 viewport the
|
||||
* full bitmap is ~10 MB; the downsampled one is ~2.5 MB. The full bitmap is
|
||||
* recycled immediately after scaling.
|
||||
*/
|
||||
private fun handleScreenshot(): Pair<Int, String> {
|
||||
val result = AtomicReference("")
|
||||
val failed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = ensureView()
|
||||
try {
|
||||
val width = wv.width.takeIf { it > 0 } ?: 1080
|
||||
val height = wv.height.takeIf { it > 0 } ?: 1920
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
wv.draw(canvas)
|
||||
|
||||
// Downsample to bound the PNG size and the base64 string.
|
||||
val scaled = Bitmap.createScaledBitmap(
|
||||
bmp,
|
||||
width / SCREENSHOT_SCALE,
|
||||
height / SCREENSHOT_SCALE,
|
||||
true,
|
||||
)
|
||||
if (scaled !== bmp) bmp.recycle()
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 85, baos)
|
||||
scaled.recycle()
|
||||
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
result.set(JSONObject().apply {
|
||||
put("data", b64)
|
||||
put("mime", "image/png")
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
// A 200 with an error body would be read by the kernel as a
|
||||
// screenshot whose data is empty, and it would build a `data:`
|
||||
// URI out of nothing. The status is what tells it this failed.
|
||||
result.set("""{"error":"screenshot failed: ${e.message}"}""")
|
||||
failed.set(true)
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
if (failed.get()) return 500 to result.get()
|
||||
return 200 to result.get()
|
||||
}
|
||||
|
||||
/** POST /click {"selector":"..."} → {"ok":true|false} */
|
||||
private fun handleClick(body: String): Pair<Int, String> {
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
?: return 500 to """{"error":"invalid request body"}"""
|
||||
val selector = json.optString("selector", "")
|
||||
if (selector.isEmpty()) return 500 to """{"error":"missing selector"}"""
|
||||
|
||||
val result = AtomicReference("")
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = ensureView()
|
||||
// Escape the selector for embedding in a JS string.
|
||||
val escaped = selector.replace("\\", "\\\\").replace("'", "\\'")
|
||||
val script = """
|
||||
(function(){
|
||||
try {
|
||||
var el = document.querySelector('$escaped');
|
||||
if (!el) return JSON.stringify({ok: false});
|
||||
el.click();
|
||||
return JSON.stringify({ok: true});
|
||||
} catch(e) {
|
||||
return JSON.stringify({ok: false});
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(script) { value ->
|
||||
val parsed = parseJsResult(value)
|
||||
result.set(parsed ?: """{"ok":false}""")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
return 200 to result.get()
|
||||
}
|
||||
|
||||
/** POST /eval {"script":"..."} → {"result":"..."} */
|
||||
private fun handleEval(body: String): Pair<Int, String> {
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
?: return 500 to """{"error":"invalid request body"}"""
|
||||
val script = json.optString("script", "")
|
||||
if (script.isEmpty()) return 500 to """{"error":"missing script"}"""
|
||||
|
||||
val result = AtomicReference("")
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = ensureView()
|
||||
wv.evaluateJavascript(script) { value ->
|
||||
// The kernel expects a plain string in "result". The value from
|
||||
// evaluateJavascript is JSON-encoded; unwrap it.
|
||||
val unwrapped = parseJsResult(value) ?: ""
|
||||
result.set(JSONObject().apply {
|
||||
put("result", unwrapped)
|
||||
}.toString())
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout waiting for eval"}"""
|
||||
}
|
||||
return 200 to result.get()
|
||||
}
|
||||
|
||||
/** POST /back → 200 */
|
||||
private fun handleBack(): Pair<Int, String> {
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
existingView()?.let { if (it.canGoBack()) it.goBack() }
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
return 200 to "{}"
|
||||
}
|
||||
|
||||
/** POST /forward → 200 */
|
||||
private fun handleForward(): Pair<Int, String> {
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
existingView()?.let { if (it.canGoForward()) it.goForward() }
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
return 200 to "{}"
|
||||
}
|
||||
|
||||
/** POST /reload → 200 */
|
||||
private fun handleReload(): Pair<Int, String> {
|
||||
val latch = CountDownLatch(1)
|
||||
mainHandler.post {
|
||||
val wv = existingView()
|
||||
// A view sitting on the browser's own error page is not on the site,
|
||||
// and reloading it would retry the error page itself. The address
|
||||
// that failed is loaded again instead — which is the page a caller
|
||||
// means by "reload" — read before `startNavigation` clears it.
|
||||
val failed = if (wv != null && (wv.url ?: "").startsWith("chrome-error:"))
|
||||
loadFailure()?.first else null
|
||||
startNavigation()
|
||||
if (wv != null) {
|
||||
if (failed != null) wv.loadUrl(failed) else wv.reload()
|
||||
}
|
||||
latch.countDown()
|
||||
}
|
||||
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
return 500 to """{"error":"timeout"}"""
|
||||
}
|
||||
return 200 to "{}"
|
||||
}
|
||||
|
||||
/** POST /requests {} → {"requests":[{"method","url","kind"},…]} */
|
||||
private fun handleRequests(): Pair<Int, String> {
|
||||
val arr = JSONArray()
|
||||
for (r in requests()) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("method", r.method)
|
||||
put("url", r.url)
|
||||
put("kind", r.kind)
|
||||
})
|
||||
}
|
||||
return 200 to JSONObject().apply { put("requests", arr) }.toString()
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Unwrap the result of `evaluateJavascript`. The callback receives a
|
||||
* JSON-encoded string: a JS string "hello" arrives as `"\"hello\""`, a
|
||||
* number 42 as `"42"`, null as `"null"`. Strip the outer JSON encoding to
|
||||
* get the raw value.
|
||||
*/
|
||||
private fun parseJsResult(value: String?): String? {
|
||||
if (value == null || value == "null") return null
|
||||
// The value is a JSON literal. If it is a JSON string, decode it.
|
||||
return try {
|
||||
if (value.startsWith("\"") && value.endsWith("\"")) {
|
||||
JSONObject().apply { }.let {
|
||||
// Use JSONObject's string decoding: wrap in an object.
|
||||
JSONObject("""{"v":$value}""").optString("v", "")
|
||||
}
|
||||
} else {
|
||||
value
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* The phone's DNS servers, as the kernel needs them.
|
||||
*
|
||||
* The kernel is a CGO-free Go binary, and Go's own source explains why that
|
||||
* matters:
|
||||
*
|
||||
* ```
|
||||
* // DNS requests don't work on Android, so prefer the cgo resolver.
|
||||
* // Issue #10714.
|
||||
* case "android":
|
||||
* ```
|
||||
*
|
||||
* A normal Android program resolves names through `getaddrinfo`, which reaches
|
||||
* the `netd` daemon and gets the servers of the network that is actually up.
|
||||
* A static binary has no cgo resolver, so Go reads `/etc/resolv.conf` instead —
|
||||
* and Android has no such file. Go then falls back to `127.0.0.1:53`, where
|
||||
* nothing listens, and every lookup fails: the setup screen reports
|
||||
* "连接失败" for every provider, including the official ones, on a phone whose
|
||||
* browser reaches them fine.
|
||||
*
|
||||
* So they are read here and handed to the kernel through `REASONIX_DNS`. This is
|
||||
* the same source the system itself uses, so a VPN, a private DNS hostname, or a
|
||||
* network that changed since boot is all reflected without this code knowing
|
||||
* anything about them.
|
||||
*/
|
||||
object Dns {
|
||||
|
||||
/**
|
||||
* The active network's servers, comma separated, or "" when there is none.
|
||||
*
|
||||
* Read from the *active* network rather than from every link, because a phone
|
||||
* commonly has several at once — Wi-Fi, mobile data, a VPN — and the servers
|
||||
* of a network that is not carrying traffic are the wrong ones to ask.
|
||||
*/
|
||||
fun servers(context: Context): String {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return ""
|
||||
val properties: LinkProperties = runCatching { manager.activeNetwork }.getOrNull()
|
||||
?.let { network -> runCatching { manager.getLinkProperties(network) }.getOrNull() }
|
||||
?: return ""
|
||||
return properties.dnsServers
|
||||
.mapNotNull { address -> address.hostAddress }
|
||||
.filter { it.isNotBlank() }
|
||||
.filter { routable(it) }
|
||||
.sortedBy { preference(it) }
|
||||
.joinToString(",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an address can actually carry a query.
|
||||
*
|
||||
* A link-local address (fe80::/10) without its zone cannot: the packet has
|
||||
* no interface to leave by, so it is dropped, nothing answers, and the
|
||||
* lookup hangs until the kernel's own timeout expires — which is reported as
|
||||
* a timeout rather than as a failure to resolve, and so reads like the
|
||||
* network is slow instead of like the address is unusable.
|
||||
*
|
||||
* Android reports the zone as a `%wlan0` suffix on `hostAddress`, and the
|
||||
* address is only usable with it kept. Nothing here trims it: an earlier
|
||||
* version called `substringBefore('%')`, which turned a usable link-local
|
||||
* server into an unroutable one.
|
||||
*/
|
||||
private fun routable(host: String): Boolean =
|
||||
!(host.startsWith("fe80:", ignoreCase = true) && !host.contains('%'))
|
||||
|
||||
/**
|
||||
* IPv4 first, then IPv6.
|
||||
*
|
||||
* A phone commonly reports both, and its IPv6 entry is often a router's
|
||||
* link-local address — reachable on one network and not the next. The
|
||||
* kernel tries the servers in the order it is given them, and stops at the
|
||||
* first that answers; putting the address family that shares the network's
|
||||
* own numbering first means its timeout is spent on a server that works.
|
||||
*/
|
||||
private fun preference(host: String): Int = if (host.contains(':')) 1 else 0
|
||||
|
||||
/**
|
||||
* The private-DNS setting, for the one case the address list cannot answer.
|
||||
*
|
||||
* Since Android 9 a phone may use DNS-over-TLS, and then `dnsServers` holds a
|
||||
* name rather than an address — a name this kernel cannot resolve *because*
|
||||
* resolving it is the thing that is broken. There is no way for the shell to
|
||||
* do that lookup for it, so this reports the situation instead of handing
|
||||
* over an unusable value: the caller says so in the log, and the person can
|
||||
* turn private DNS off or set it to automatic.
|
||||
*/
|
||||
fun looksLikeDot(servers: String): Boolean =
|
||||
servers.isNotBlank() && servers.split(',').any { part ->
|
||||
val host = part.trim()
|
||||
host.isNotEmpty() && host.none { it == ':' } && !host.all { it.isDigit() || it == '.' }
|
||||
}
|
||||
|
||||
/** Whether the system requires a VPN or private DNS, which changes nothing
|
||||
* here but is worth saying in a bug report. */
|
||||
fun describe(context: Context): String {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return "no connectivity service"
|
||||
val network = runCatching { manager.activeNetwork }.getOrNull() ?: return "no active network"
|
||||
val properties = runCatching { manager.getLinkProperties(network) }.getOrNull()
|
||||
?: return "no link properties"
|
||||
// The DHCP server arrived in API 30, not 23: asked for below that it is a
|
||||
// NoSuchMethodError on a device this app supports (minSdk 26), and the
|
||||
// answer is only ever read out of this string by a person debugging.
|
||||
val dhcp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
properties.dhcpServerAddress?.hostAddress
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return "iface=${properties.interfaceName} dns=${properties.dnsServers.joinToString(",") { it.hostAddress ?: "?" }}" +
|
||||
(dhcp?.let { " dhcp=$it" } ?: "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.CookieManager
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Putting a file the page produced onto the phone's disk.
|
||||
*
|
||||
* A WebView has no downloads shelf and cannot write to the system's document
|
||||
* picker itself, so the two halves are joined here: the page asks, the picker
|
||||
* answers with a `content://` the app has been granted, and this copies the
|
||||
* bytes across.
|
||||
*
|
||||
* The request carries the WebView's own cookie, read out of the jar the page
|
||||
* authenticated in. That is the only reason this is not a plain `openStream`:
|
||||
* the kernel's download route is behind the session the page established, and a
|
||||
* shell that fetched without it would get a 403 and no explanation.
|
||||
*/
|
||||
object Downloader {
|
||||
|
||||
suspend fun run(
|
||||
context: Context,
|
||||
url: String,
|
||||
userAgent: String,
|
||||
mime: String,
|
||||
name: String,
|
||||
target: Uri,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 60_000
|
||||
requestMethod = "GET"
|
||||
if (userAgent.isNotBlank()) setRequestProperty("User-Agent", userAgent)
|
||||
// The download route answers with the same session cookie the
|
||||
// page is using; without it the kernel refuses the request.
|
||||
cookieFor(url)?.let { setRequestProperty("Cookie", it) }
|
||||
}
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
throw IllegalStateException(
|
||||
context.getString(R.string.download_failed_status, code),
|
||||
)
|
||||
}
|
||||
val resolver = context.contentResolver
|
||||
val output = resolver.openOutputStream(target, "w")
|
||||
?: throw FileNotFoundException(target.toString())
|
||||
output.use { out ->
|
||||
connection.inputStream.use { input -> input.copyTo(out) }
|
||||
}
|
||||
// `copyTo` answers with a byte count; what the caller wants to
|
||||
// know is that there were no bytes missing, not how many.
|
||||
Unit
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cookie the page authenticated with, if any.
|
||||
*
|
||||
* `CookieManager` is the WebView's own store, so this asks the exact jar the
|
||||
* request will be checked against rather than a copy this shell kept.
|
||||
*/
|
||||
private fun cookieFor(url: String): String? =
|
||||
runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** A short receipt for a tap that otherwise does nothing visible. */
|
||||
fun report(context: Context, name: String, result: Result<Unit>) {
|
||||
val message = result.fold(
|
||||
onSuccess = { context.getString(R.string.download_saved, name) },
|
||||
onFailure = { failure ->
|
||||
context.getString(R.string.download_failed, failure.message ?: "")
|
||||
},
|
||||
)
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The Reasonix kernel, as this app runs it: one child process serving loopback,
|
||||
* plus the two things it needs on disk to start — the page it serves and a
|
||||
* credential for this launch.
|
||||
*
|
||||
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
|
||||
* a phone does not have. That host binds a socket, mints a credential, speaks a
|
||||
* handshake down a pipe and holds a lease; here the credential goes through
|
||||
* `--token-file` and the bound address through `--port-file`, because a phone's
|
||||
* app process has no pipe to hand a child and no reason to care which port was
|
||||
* free. Everything after that — the kernel's own gate, its auth middleware, its
|
||||
* CSRF check — is untouched, so the security of the surface is the one upstream
|
||||
* tested, not one this shell invented.
|
||||
*
|
||||
* One instance lives in the app process. The activity asks it for a URL; the
|
||||
* foreground service asks it to stay alive. Both see the same process, so there
|
||||
* is nothing to synchronise beyond a lock on start/stop.
|
||||
*/
|
||||
class Kernel(private val context: Context) {
|
||||
|
||||
/** Where everything the kernel owns lives. Nothing here is backed up. */
|
||||
private val home = File(context.filesDir, "home")
|
||||
|
||||
/**
|
||||
* The folder the agent works in, which is not the folder the kernel lives in.
|
||||
*
|
||||
* These used to be the same directory, and that is what made the workbench
|
||||
* unreadable: the kernel's home holds `config.toml`, `projects/`, `cache/`,
|
||||
* `stats/` and the rest of its own bookkeeping, and a workspace pointed at
|
||||
* it shows all of that as if the person had put it there. Reported as "why
|
||||
* does the default workspace have so many files, the desktop version does
|
||||
* not generate these" — the desktop does generate them, in a different
|
||||
* directory, because its workspace is whatever folder it was opened on.
|
||||
*
|
||||
* Split apart, the workbench shows what the agent produced and nothing else.
|
||||
* `write_roots` follows the workspace, so the file tools stay confined to
|
||||
* the folder the person actually works in rather than to the kernel's own
|
||||
* home — which is the stricter answer, and the one the desktop already has.
|
||||
*/
|
||||
private val workspaceDir = File(context.filesDir, "workspace")
|
||||
|
||||
/**
|
||||
* The directory the kernel calls `REASONIX_HOME`, for the one caller that
|
||||
* has to put a file where the kernel can read it — a private key the person
|
||||
* picked from their Documents folder.
|
||||
*/
|
||||
fun homeDir(): File = home
|
||||
|
||||
/** The folder the agent works in. */
|
||||
fun workspaceDir(): File = workspaceDir
|
||||
|
||||
/** The built page, unpacked once per version. */
|
||||
private val pageDir = File(context.filesDir, "page")
|
||||
|
||||
private val tokenFile = File(home, "remote/launch.token")
|
||||
private val portFile = File(home, "remote/serve.port")
|
||||
private val pidFile = File(home, "remote/serve.pid")
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
private var process: Process? = null
|
||||
|
||||
/** The port the shell's browser HTTP server listens on, set by the
|
||||
* activity before `start()` is called. Zero means no server is up, and
|
||||
* the kernel leaves its browser tools suspended (the correct degradation
|
||||
* on a phone with no Chromium). */
|
||||
@Volatile
|
||||
var browserShellPort: Int = 0
|
||||
|
||||
/** The bearer credential for that server, set beside the port. Sent on
|
||||
* every browser call so the server can refuse anything that did not come
|
||||
* from the kernel this shell started — loopback on a phone is reachable
|
||||
* by every other app. Empty when there is no server. */
|
||||
@Volatile
|
||||
var browserShellToken: String = ""
|
||||
|
||||
/** What the WebView should load, once the kernel has bound a port. */
|
||||
@Volatile
|
||||
var origin: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* The launch credential, as the URL fragment the page trades for a cookie.
|
||||
* Kept in memory only: the file on disk belongs to the kernel's own
|
||||
* bookkeeping, and a phone's app data is not a place to leave a second copy.
|
||||
*/
|
||||
@Volatile
|
||||
private var token: String? = null
|
||||
|
||||
/**
|
||||
* The URL the WebView loads first. It carries the credential in the
|
||||
* fragment, which the page exchanges for an HttpOnly cookie before it makes
|
||||
* any request — the same path a shared `reasonix web` link takes, so the
|
||||
* token never enters a request line, a history entry or a log.
|
||||
*/
|
||||
fun entryUrl(): String? {
|
||||
val origin = origin ?: return null
|
||||
val token = token ?: return null
|
||||
return "$origin/#token=$token"
|
||||
}
|
||||
|
||||
/** The plain origin, for reloads once the cookie is set. */
|
||||
fun plainUrl(): String? = origin
|
||||
|
||||
fun isRunning(): Boolean = process?.isAlive == true
|
||||
|
||||
/**
|
||||
* Bring the kernel up, or answer the URL of the one already up.
|
||||
*
|
||||
* Starting is idempotent by intent: the activity and the service both call
|
||||
* this, and whichever arrives second joins the running kernel instead of
|
||||
* racing it for the port.
|
||||
*/
|
||||
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
|
||||
synchronized(lock) {
|
||||
origin?.let { return@withContext Result.success(it) }
|
||||
|
||||
runCatching {
|
||||
val binary = kernelBinary()
|
||||
publishPage()
|
||||
prepareHome()
|
||||
val launchToken = mintToken()
|
||||
token = launchToken
|
||||
|
||||
val child = launch(binary, launchToken)
|
||||
|
||||
// The port is the kernel's answer, not this shell's guess. It
|
||||
// writes the file after binding, so waiting on the file is
|
||||
// waiting on the bind.
|
||||
val bound = awaitPort(child)
|
||||
val url = "http://$bound"
|
||||
|
||||
// A kernel that bound a port and then died would look healthy
|
||||
// here and broken in the WebView; the two facts are checked
|
||||
// together, and only a kernel that is answering is adopted.
|
||||
awaitServing(child, url)
|
||||
origin = url
|
||||
process = child
|
||||
url
|
||||
}.onFailure { origin = null; process = null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the kernel.
|
||||
*
|
||||
* The graceful signal first: the kernel drains its listeners and closes the
|
||||
* runtimes it opened, which is what lets a later launch adopt the sessions
|
||||
* rather than find them locked. `destroyForcibly` is the second attempt, and
|
||||
* only after a wait — a phone that is being shut down should not keep a
|
||||
* half-killed agent.
|
||||
*/
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
val p = process ?: return
|
||||
process = null
|
||||
origin = null
|
||||
token = null
|
||||
runCatching {
|
||||
p.destroy()
|
||||
if (!p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
p.destroyForcibly()
|
||||
}
|
||||
}
|
||||
portFile.delete()
|
||||
pidFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the two inputs that ship inside the APK -------------------------
|
||||
|
||||
/**
|
||||
* The kernel binary.
|
||||
*
|
||||
* It is packaged as a native library, so Android has already extracted it to
|
||||
* a directory it allows execution from and given it the executable bit —
|
||||
* which is the one thing a file under app storage does not get for free.
|
||||
*/
|
||||
private fun kernelBinary(): File {
|
||||
val dir = File(context.applicationInfo.nativeLibraryDir)
|
||||
val binary = File(dir, "libreasonix.so")
|
||||
if (!binary.exists()) {
|
||||
throw IllegalStateException(
|
||||
"内核未随 APK 安装:${binary.path}\n这是一次构建问题,请重新安装完整 APK。",
|
||||
)
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the bundled page where the kernel can serve it.
|
||||
*
|
||||
* It goes to app storage rather than being served out of the APK because the
|
||||
* kernel's `--page` is a directory on a real filesystem; there is no
|
||||
* archive-aware variant, and adding one would be a change to the kernel for
|
||||
* this shell's convenience alone.
|
||||
*
|
||||
* Unpacking is versioned: the marker holds the app's version name, so an
|
||||
* update replaces the page and a relaunch does not. A partially written
|
||||
* directory — the app killed mid-copy — is caught by the same marker never
|
||||
* having been written, so the next launch rewrites it.
|
||||
*/
|
||||
private fun publishPage() {
|
||||
val marker = File(pageDir, ".unpacked")
|
||||
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
|
||||
if (marker.isFile && marker.readText().trim() == stamp) return
|
||||
|
||||
pageDir.deleteRecursively()
|
||||
pageDir.mkdirs()
|
||||
copyAssetTree(context.assets, "page", pageDir)
|
||||
|
||||
if (!File(pageDir, "index.html").isFile) {
|
||||
throw IllegalStateException("随 APK 分发的页面不完整:缺少 index.html")
|
||||
}
|
||||
marker.writeText(stamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory tree out of assets.
|
||||
*
|
||||
* `AssetManager.list` on a file answers with an empty array, so a "file" is
|
||||
* told from a directory by trying to open it — which is the only signal the
|
||||
* API offers and the reason this is not a one-liner.
|
||||
*/
|
||||
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
|
||||
val children = assets.list(from).orEmpty()
|
||||
if (children.isEmpty()) {
|
||||
// A leaf: open it as a stream. A directory that happens to be empty
|
||||
// is indistinguishable and harmless to skip.
|
||||
runCatching {
|
||||
assets.open(from).use { input ->
|
||||
FileOutputStream(to).use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
to.mkdirs()
|
||||
for (child in children) {
|
||||
copyAssetTree(assets, "$from/$child", File(to, child))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the kernel's home.
|
||||
*
|
||||
* `REASONIX_HOME` is what every path in the kernel hangs off — config,
|
||||
* sessions, the remote state directory its token file lands in — so pointing
|
||||
* it at app storage is what keeps a phone's Reasonix separate from anything
|
||||
* else and away from global storage entirely.
|
||||
*/
|
||||
private fun prepareHome() {
|
||||
File(home, "remote").mkdirs()
|
||||
// The workspace directory not existing yet is what marks the first run
|
||||
// after this build split it from the kernel's home. That is the one
|
||||
// moment a migration is due, and doing it here means it happens before
|
||||
// the kernel is asked for anything.
|
||||
val firstSplit = !workspaceDir.exists()
|
||||
workspaceDir.mkdirs()
|
||||
if (firstSplit) {
|
||||
adoptSessionsFromHome()
|
||||
}
|
||||
// Every launch, not only the first. The rewrite is cheap and
|
||||
// idempotent, and an entry that survived the first pass would otherwise
|
||||
// survive forever: `firstSplit` is spent the moment the directory
|
||||
// exists, so a build that cleaned the list once and missed leaves the
|
||||
// home on the sidebar for good. Running it unconditionally costs one
|
||||
// small read and makes the whole thing self-correcting.
|
||||
forgetHomeFromWorkspaceList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the kernel's own home from the sidebar's remembered projects.
|
||||
*
|
||||
* The list is written whenever a pane opens, and until this build the pane
|
||||
* opened on the home itself — so an upgraded install carries a "project"
|
||||
* that is really the kernel's own directory. Leaving it there would put the
|
||||
* person back where they started: one tap on it and the workbench is full
|
||||
* of `config.toml`, `cache/` and `projects/` again, which is the complaint
|
||||
* this whole change answers. Only that one path is removed; every folder
|
||||
* someone added on purpose stays.
|
||||
*
|
||||
* Parsed rather than pattern-matched, because the file's two shapes — a bare
|
||||
* array of paths and an object with `paths` and `launch` — both have to keep
|
||||
* whatever else they hold.
|
||||
*/
|
||||
private fun forgetHomeFromWorkspaceList() {
|
||||
val file = File(home, "serve-workspaces.json")
|
||||
if (!file.isFile) return
|
||||
val text = runCatching { file.readText() }.getOrNull() ?: return
|
||||
val wrong = runCatching { homeNames() }.getOrNull() ?: return
|
||||
val rewritten = runCatching { withoutHome(text, wrong) }.getOrNull() ?: return
|
||||
if (!rewritten.dropped) return
|
||||
runCatching { file.writeText(rewritten.text) }
|
||||
.onSuccess { Log.i(TAG, "dropped the kernel home from the project list") }
|
||||
.onFailure { Log.w(TAG, "could not rewrite the project list: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every name of the kernel's home on this device — not just the one this
|
||||
* process happens to write.
|
||||
*
|
||||
* Android shows one directory under two names: `/data/data/<pkg>` and
|
||||
* `/data/user/0/<pkg>` are two mounts of the same tree, the same inode
|
||||
* either way, and which name a path carries depends on how it was written
|
||||
* rather than on where it points. The list is written by the kernel, whose
|
||||
* working directory this shell set from a path it built itself; a lookup
|
||||
* that compared against only one spelling would silently keep the entry
|
||||
* this exists to remove. Both names are compared, so either spelling is
|
||||
* recognised.
|
||||
*/
|
||||
private fun homeNames(): Set<String> {
|
||||
val path = home.absolutePath
|
||||
val names = mutableSetOf(path)
|
||||
val user = "/data/user/0/"
|
||||
val data = "/data/data/"
|
||||
when {
|
||||
path.startsWith(user) -> names.add(data + path.removePrefix(user))
|
||||
path.startsWith(data) -> names.add(user + path.removePrefix(data))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** What a rewrite produced, and whether it actually removed anything. */
|
||||
private class Rewrite(val text: String, val dropped: Boolean)
|
||||
|
||||
/**
|
||||
* The list without the home, in whichever shape it arrived, and whether
|
||||
* anything was actually dropped from it.
|
||||
*
|
||||
* The flag matters because `JSONObject` re-serialises: it escapes every
|
||||
* `/` and does not preserve key order, so a round trip never reproduces
|
||||
* the original bytes. Deciding to write on "the text differs" alone would
|
||||
* therefore rewrite the kernel's file on every single launch, log a drop
|
||||
* that never happened, and race the kernel for a file it also writes.
|
||||
*/
|
||||
private fun withoutHome(text: String, wrong: Set<String>): Rewrite {
|
||||
if (text.trimStart().startsWith("[")) {
|
||||
val paths = JSONArray(text)
|
||||
val kept = JSONArray()
|
||||
var dropped = false
|
||||
for (i in 0 until paths.length()) {
|
||||
val path = paths.optString(i)
|
||||
if (path in wrong) {
|
||||
dropped = true
|
||||
continue
|
||||
}
|
||||
kept.put(path)
|
||||
}
|
||||
return if (dropped) Rewrite(kept.toString(), true) else Rewrite(text, false)
|
||||
}
|
||||
val list = JSONObject(text)
|
||||
val kept = JSONArray()
|
||||
var dropped = false
|
||||
val paths = list.optJSONArray("paths")
|
||||
if (paths != null) {
|
||||
for (i in 0 until paths.length()) {
|
||||
val path = paths.optString(i)
|
||||
if (path in wrong) {
|
||||
dropped = true
|
||||
continue
|
||||
}
|
||||
kept.put(path)
|
||||
}
|
||||
if (dropped) list.put("paths", kept)
|
||||
}
|
||||
if (list.optString("launch") in wrong) {
|
||||
list.remove("launch")
|
||||
dropped = true
|
||||
}
|
||||
return if (dropped) Rewrite(list.toString(), true) else Rewrite(text, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the conversations that were filed under the kernel's own home.
|
||||
*
|
||||
* The transcript store is keyed by the workspace root — one directory per
|
||||
* project, under `<state>/projects/<slug>/sessions` — and until this build
|
||||
* the root was the home itself. Splitting the two therefore moves every
|
||||
* existing conversation to a slug nothing looks under, which reads as "my
|
||||
* history is gone" rather than as a reorganisation. So the old directory is
|
||||
* renamed onto the new slug once.
|
||||
*
|
||||
* Renaming the project directory rather than just `sessions/` carries the
|
||||
* rest of it too: `memory/` is filed the same way, and a project's memory
|
||||
* lost while its transcripts were kept would be a stranger failure than
|
||||
* either alone.
|
||||
*
|
||||
* The slug is the absolute path with its separators flattened, which is
|
||||
* what `config.WorkspaceSlug` does on the kernel's side. Only the home's own
|
||||
* slug is touched: a slug for some other folder is left exactly where it is,
|
||||
* because those are projects someone added on purpose.
|
||||
*/
|
||||
private fun adoptSessionsFromHome() {
|
||||
val projects = File(home, "projects")
|
||||
if (!projects.isDirectory) return
|
||||
val from = projects.resolve(slugOf(home.absolutePath))
|
||||
if (!from.isDirectory) return
|
||||
val to = projects.resolve(slugOf(workspaceDir.absolutePath))
|
||||
// A target already there means a previous run half-finished, or the
|
||||
// person opened this folder before. Either way its contents win:
|
||||
// merging two transcript directories is not a thing this can do safely,
|
||||
// and the old one is still on disk to be looked at.
|
||||
if (to.exists()) {
|
||||
Log.i(TAG, "${to.name} already exists; leaving ${from.name} in place")
|
||||
return
|
||||
}
|
||||
if (!from.renameTo(to)) {
|
||||
Log.w(TAG, "could not move ${from.name} to ${to.name}; history stays under the old slug")
|
||||
} else {
|
||||
Log.i(TAG, "moved conversations from ${from.name} to ${to.name}")
|
||||
}
|
||||
}
|
||||
|
||||
/** The kernel's `config.WorkspaceSlug`, for the two paths that matter here. */
|
||||
private fun slugOf(path: String): String = path.replace('/', '-').replace('\\', '-').replace(':', '-')
|
||||
|
||||
/**
|
||||
* The launch credential.
|
||||
*
|
||||
* Minted here rather than read from configuration: a credential a person can
|
||||
* set is one anything that can read their files can present. 32 bytes, hex,
|
||||
* the same shape the desktop host mints.
|
||||
*/
|
||||
private fun mintToken(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
val token = bytes.joinToString("") { "%02x".format(it) }
|
||||
tokenFile.parentFile?.mkdirs()
|
||||
tokenFile.writeText("$token\n")
|
||||
// 0600 by intent, and the app's own private directory means it is
|
||||
// already unreachable to every other app on the device.
|
||||
runCatching {
|
||||
tokenFile.setReadable(false, false)
|
||||
tokenFile.setReadable(true, true)
|
||||
tokenFile.setWritable(false, false)
|
||||
tokenFile.setWritable(true, true)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the child.
|
||||
*
|
||||
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
|
||||
* makes it announce which. `--auth token` is the mode a phone needs: the
|
||||
* page authenticates, and a state-changing request without the credential
|
||||
* is refused. `--no-open` is belt and braces — a serve never opens a
|
||||
* browser, and a phone has no browser to open.
|
||||
*/
|
||||
private fun launch(binary: File, token: String): Process {
|
||||
portFile.delete()
|
||||
pidFile.delete()
|
||||
// The folder the agent works in, resolved before the command line is
|
||||
// built so it can be named on it. See the note under the environment
|
||||
// block for why naming it is not the same as setting the directory.
|
||||
val workspace = launchWorkspaceDir()
|
||||
val command = listOf(
|
||||
binary.absolutePath,
|
||||
"serve",
|
||||
"--addr", "127.0.0.1:0",
|
||||
"--auth", "token",
|
||||
"--token-file", tokenFile.absolutePath,
|
||||
"--port-file", portFile.absolutePath,
|
||||
"--pid-file", pidFile.absolutePath,
|
||||
"--no-open",
|
||||
"-page", pageDir.absolutePath,
|
||||
// The project root, said outright. Without this the kernel derives
|
||||
// it from its working directory, and on this phone the two did not
|
||||
// agree: the sidebar named the shared folder, `write_roots` confined
|
||||
// the file tools to the app's own directory, and every write into the
|
||||
// folder the person was looking at came back
|
||||
// `workspace.write_outside_scope`. A flag is a fact the kernel was
|
||||
// told rather than one it inferred from a mount it sees differently.
|
||||
"--dir", workspace.absolutePath,
|
||||
)
|
||||
val builder = ProcessBuilder(command)
|
||||
builder.environment().apply {
|
||||
put("REASONIX_HOME", home.absolutePath)
|
||||
put("HOME", home.absolutePath)
|
||||
// The kernel's own language, for its tool failures, approval
|
||||
// prompts and error sentences. Without this it detects from the
|
||||
// POSIX locale variables, and Android sets none of them for an
|
||||
// app: the kernel then answers in English while the page in front
|
||||
// of the person is Chinese, and every sentence added for a Chinese
|
||||
// reader was dead text. The tag, not the bare language, so a
|
||||
// Traditional Chinese device resolves to zh-TW rather than zh.
|
||||
// An explicit `language` in the kernel's config.toml still wins:
|
||||
// DetectLanguage applies the configured value over this.
|
||||
val languageTag = Locale.getDefault().toLanguageTag()
|
||||
if (languageTag.isNotBlank() && languageTag != "und") {
|
||||
put("REASONIX_LANG", languageTag)
|
||||
}
|
||||
// Android has no /tmp. Everything that wants a scratch directory —
|
||||
// the shell tool, tool output spooling, sqlite journals — reads this.
|
||||
put("TMPDIR", context.cacheDir.absolutePath)
|
||||
// The phone's own DNS servers. A CGO-free Go binary cannot read them
|
||||
// for itself — Android keeps them in `netd`, and Go looks for a
|
||||
// `/etc/resolv.conf` that does not exist — so without this the kernel
|
||||
// resolves nothing and every provider probe reports "连接失败" on a
|
||||
// device whose browser reaches the same address fine.
|
||||
val dns = Dns.servers(context)
|
||||
if (dns.isNotBlank()) {
|
||||
put("REASONIX_DNS", dns)
|
||||
Log.i(TAG, "kernel DNS: $dns (${Dns.describe(context)})")
|
||||
if (Dns.looksLikeDot(dns)) {
|
||||
// Private DNS (DNS-over-TLS) is on, and it hands back a
|
||||
// hostname this kernel cannot resolve by definition. Saying
|
||||
// so here is what turns "连不上" into something actionable.
|
||||
Log.w(TAG, "kernel DNS looks like a private-DNS hostname; turn private DNS off or set it to automatic")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "kernel DNS: none reported by the system")
|
||||
}
|
||||
// The shell's browser server, when one is listening. The kernel
|
||||
// reads this to bind its browser tools to the shell's WebView
|
||||
// instead of looking for a Chromium binary that does not exist on
|
||||
// a phone. When absent (zero), the variable is not set and the
|
||||
// kernel keeps the browser tools suspended — the correct
|
||||
// degradation for a phone with no engine to drive.
|
||||
val shellPort = browserShellPort
|
||||
if (shellPort > 0) {
|
||||
put("REASONIX_SHELL_BROWSER_PORT", shellPort.toString())
|
||||
// The credential the server checks. Set only alongside the
|
||||
// port: a token with nothing listening would be a variable the
|
||||
// kernel carries for no reason.
|
||||
val shellToken = browserShellToken
|
||||
if (shellToken.isNotBlank()) {
|
||||
put("REASONIX_SHELL_BROWSER_TOKEN", shellToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The agent's folder, not the kernel's. `REASONIX_HOME` above is still
|
||||
// where the config, the sessions and the transcripts live; this is what
|
||||
// the workspace root resolves to, because the kernel reads the working
|
||||
// directory for it.
|
||||
//
|
||||
// The folder the person last opened, when it is still usable — the same
|
||||
// answer the desktop host gives (`launchWorkspace`), and the reason a
|
||||
// phone session used to work in the app's own `files/workspace` while
|
||||
// the sidebar said it was in the shared folder: the launch entry in
|
||||
// `serve-workspaces.json` was written and then ignored, and `write_roots`
|
||||
// is derived from the working directory, so the file tools were confined
|
||||
// to the app's private folder and every write into the shared one was
|
||||
// refused. `workspaceDir` is still the answer for a first run and for a
|
||||
// folder since deleted, but the choice is never silent — see
|
||||
// [launchWorkspaceDir]. `workspace` was resolved above so it could also
|
||||
// be named on the command line.
|
||||
builder.directory(workspace)
|
||||
// `PWD` is what the kernel's own `os.Getwd` prefers: Go uses it whenever
|
||||
// it names the same directory as `.`, which is exactly this case, and it
|
||||
// is the only way to make the kernel resolve its root to the string this
|
||||
// shell chose. Without it `os.Getwd` falls back to the kernel's view of
|
||||
// the mount, and on Android `/storage/emulated/0/...` and the path the
|
||||
// FUSE layer reports for it need not be spelled the same way. The kernel
|
||||
// derives `write_roots` from that root, so the two spellings have to be
|
||||
// the same one.
|
||||
builder.environment().put("PWD", workspace.absolutePath)
|
||||
builder.redirectErrorStream(true)
|
||||
val process = builder.start()
|
||||
|
||||
// The kernel's own log is the only place a startup failure says why, and
|
||||
// a phone has no terminal to read. It is drained on a thread and kept
|
||||
// ring-buffered so the About screen can show the last of it.
|
||||
Thread({ drain(process) }, "kernel-log").apply { isDaemon = true }.start()
|
||||
return process
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder the person last opened, or null when there is not one.
|
||||
*
|
||||
* `serve-workspaces.json` is the kernel's own record of the projects the
|
||||
* sidebar lists, and its `launch` entry is which of them a session starts
|
||||
* in. Both shapes the file has are read — a bare array of paths, and an
|
||||
* object with `paths` and `launch` — because both are written in the wild
|
||||
* and a reader that only knew one would silently fall back.
|
||||
*
|
||||
* The entry has to be one of the listed paths, which is the same rule the
|
||||
* kernel applies when it reads this file (`workspacelist.Read` blanks a
|
||||
* `launch` that is not in `paths`). It matters more here than there: the
|
||||
* working directory decides `write_roots`, so whatever this answers is
|
||||
* where the agent's file tools may write — and on a phone that permission
|
||||
* reaches all of shared storage. A file with a `launch` outside its own
|
||||
* list is one this shell did not write, and it gets the listed answer.
|
||||
*
|
||||
* Null for anything unusable: no file, unreadable JSON, a launch that is
|
||||
* not among the listed folders, an entry that has since been deleted, or a
|
||||
* path that is now a file. Every one of those means the same thing to the
|
||||
* caller — start where this shell always started.
|
||||
*/
|
||||
private fun rememberedLaunchDir(): File? {
|
||||
val file = File(home, "serve-workspaces.json")
|
||||
if (!file.isFile) return null
|
||||
val text = runCatching { file.readText() }.getOrNull() ?: return null
|
||||
val launch = runCatching {
|
||||
// A bare array is the file's other shape — the one older kernels
|
||||
// wrote, and therefore the one a person upgrading is most likely to
|
||||
// have. Without this branch `JSONObject(text)` throws on it and the
|
||||
// whole function falls back, which is the same mismatch (the
|
||||
// sidebar naming one folder, the session working in another) this
|
||||
// exists to fix. `withoutHome` below reads both shapes the same way.
|
||||
if (text.trimStart().startsWith("[")) {
|
||||
val arr = JSONArray(text)
|
||||
if (arr.length() == 0) return@runCatching null
|
||||
arr.optString(0)
|
||||
} else {
|
||||
val json = JSONObject(text)
|
||||
val paths = json.optJSONArray("paths")
|
||||
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optString(it).takeIf(String::isNotBlank) } }
|
||||
.orEmpty()
|
||||
if (paths.isEmpty()) return@runCatching null
|
||||
json.optString("launch").takeIf { it.isNotBlank() && it in paths } ?: paths.first()
|
||||
}
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val dir = runCatching { File(launch) }.getOrNull() ?: return null
|
||||
if (!dir.isDirectory) {
|
||||
// A launch entry this process cannot stat is exactly the failure the
|
||||
// caller exists to avoid, and falling back silently is what made it
|
||||
// take three releases to notice: the session opened in the app's own
|
||||
// folder while the sidebar named the shared one. A phone has no
|
||||
// terminal, so the log is the only place the reason can be said —
|
||||
// and `exists`/`readable` tell apart "deleted" from "not permitted",
|
||||
// which need different answers from the person.
|
||||
Log.w(
|
||||
TAG,
|
||||
"launch entry $launch is not a usable directory " +
|
||||
"(exists=${dir.exists()}, readable=${dir.canRead()})",
|
||||
)
|
||||
return null
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder the kernel should start in.
|
||||
*
|
||||
* Either the launch entry — the folder the person last opened, which is
|
||||
* also the one the sidebar shows — or the app's own folder when there is
|
||||
* none. Whichever it is, it is logged, because the two disagreeing is the
|
||||
* bug this exists to fix and a silent choice is what let it survive.
|
||||
*/
|
||||
private fun launchWorkspaceDir(): File {
|
||||
val remembered = rememberedLaunchDir()
|
||||
if (remembered != null) {
|
||||
Log.i(TAG, "kernel workspace: ${remembered.absolutePath} (the remembered launch project)")
|
||||
return remembered
|
||||
}
|
||||
Log.i(TAG, "kernel workspace: ${workspaceDir.absolutePath} (no remembered launch project)")
|
||||
return workspaceDir
|
||||
}
|
||||
|
||||
private val logLines = ArrayDeque<String>()
|
||||
|
||||
private fun drain(process: Process) {
|
||||
runCatching {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
synchronized(logLines) {
|
||||
logLines.addLast(line)
|
||||
while (logLines.size > LOG_KEEP) logLines.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The kernel's tail, newest last, for a bug report or the About screen. */
|
||||
fun recentLog(): String = synchronized(logLines) { logLines.joinToString("\n") }
|
||||
|
||||
private fun awaitPort(process: Process): String {
|
||||
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!process.isAlive) {
|
||||
throw IllegalStateException("内核启动即退出。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
val text = if (portFile.isFile) runCatching { portFile.readText().trim() }.getOrNull() else null
|
||||
// The file is written by the kernel on the way to serving, and the
|
||||
// kernel rewrites it with the real port when it was given :0.
|
||||
if (!text.isNullOrBlank() && text.contains(":")) return text
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
throw IllegalStateException("内核在 ${START_TIMEOUT_MS / 1000}s 内没有报告监听地址。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the bound port answers.
|
||||
*
|
||||
* A port that is open but not yet serving is the normal first second of a
|
||||
* kernel's life; a WebView pointed at it would show a connection error and
|
||||
* cache the failure, so the wait belongs here rather than in the page.
|
||||
*/
|
||||
private fun awaitServing(process: Process, origin: String) {
|
||||
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (!process.isAlive) {
|
||||
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
if (probe("$origin/")) return
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
|
||||
}
|
||||
|
||||
private fun probe(url: String): Boolean = runCatching {
|
||||
(URL(url).openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 1000
|
||||
readTimeout = 1000
|
||||
requestMethod = "GET"
|
||||
try {
|
||||
responseCode in 200..399
|
||||
} finally {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReasonixKernel"
|
||||
const val LOG_KEEP = 400
|
||||
const val POLL_MS = 120L
|
||||
const val START_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Keeps the kernel alive while the app is not on screen.
|
||||
*
|
||||
* The reason this exists at all: an agent turn that takes minutes is a normal
|
||||
* turn, and Android reclaims a backgrounded app's child process within seconds
|
||||
* of the screen going off. Without the service, pocketing the phone would kill
|
||||
* a running task — the one failure mode that makes a phone a bad client for
|
||||
* something that works on its own.
|
||||
*
|
||||
* A foreground service needs a notification the person can see, which is also
|
||||
* the honest thing to show: the kernel is running and what it is doing lives on
|
||||
* another machine.
|
||||
*/
|
||||
class KernelService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Promoted before `start()`: a service that starts work first and
|
||||
// notifies after has a window in which the system may kill it, and the
|
||||
// work it would have been protecting is exactly what is being started.
|
||||
startForegroundNotification()
|
||||
|
||||
val kernel = (application as App).kernel
|
||||
scope.launch {
|
||||
kernel.start().onFailure { failure ->
|
||||
// A kernel that will not start is a fact the person needs, and a
|
||||
// notification that only ever said "running" would be a lie.
|
||||
notify(SERVICE_NOTICE, getString(R.string.notify_failed_title), failure.message.orEmpty())
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
// Sticky, so a kernel killed under memory pressure comes back when there
|
||||
// is room — which is what a person who left a task running expects.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val notification = buildNotification(
|
||||
getString(R.string.notify_running_title),
|
||||
getString(R.string.notify_running_body),
|
||||
)
|
||||
// `dataSync` matches the manifest's declaration. On 34+ the type is
|
||||
// mandatory and a mismatch throws, so it is named in both places.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
SERVICE_NOTICE,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
startForeground(SERVICE_NOTICE, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, body: String): Notification {
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, App.CHANNEL_KERNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(open)
|
||||
// The notification is a receipt, not an alarm; swiping it away
|
||||
// leaves the kernel running, which is what the person meant by
|
||||
// swiping it.
|
||||
.setOngoing(false)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun notify(id: Int, title: String, body: String) {
|
||||
// The notification permission is granted at runtime from Android 13 and
|
||||
// the person can refuse it. Without this check the call throws
|
||||
// SecurityException and takes the service down with it — the opposite of
|
||||
// what a notification that could not be shown should do. Below 13 the
|
||||
// permission does not exist and posting always succeeds.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(this).notify(id, buildNotification(title, body))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SERVICE_NOTICE = 1
|
||||
|
||||
/** Idempotent: called on every start of the activity, and by the boot
|
||||
* path, and safe to repeat. */
|
||||
fun start(context: Context) {
|
||||
ContextCompat.startForegroundService(context, Intent(context, KernelService::class.java))
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, KernelService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,744 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* What the page can ask the phone for.
|
||||
*
|
||||
* This is the whole surface, and it is deliberately tiny. Android's
|
||||
* `@JavascriptInterface` is a bridge into the app's process from a WebView whose
|
||||
* content is a web page: every method here is reachable by anything that can run
|
||||
* script in that page, so each one is written as a verb with no address
|
||||
* parameter it does not need, and none of them can name a path outside the
|
||||
* kernel's own home.
|
||||
*
|
||||
* It is deliberately *not* the Electron preload bridge. That one exposes window
|
||||
* management, a filesystem walker, an editor launcher and a browser view host —
|
||||
* all of which are meaningless on a phone, and the two that were not (open a URL,
|
||||
* save a file) already have a way to happen here: `WebViewClient` routes external
|
||||
* links to the browser, and the page's own download reaches the WebView's
|
||||
* `DownloadListener`.
|
||||
*
|
||||
* The one thing a phone must add is `importIntoHome`: the kernel runs on this
|
||||
* device and can only read paths under its own home, so a private key the person
|
||||
* has in their Documents folder has to be copied across. Nothing else the page
|
||||
* wants is out of its reach.
|
||||
*/
|
||||
class NativeBridge(
|
||||
private val context: Context,
|
||||
private val kernel: Kernel,
|
||||
private val onOpen: (String) -> Unit,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether the document calling may use this bridge.
|
||||
*
|
||||
* `addJavascriptInterface` makes the object reachable from whatever page the
|
||||
* WebView holds, so a link the model produced — or a cleartext page on the
|
||||
* LAN — would otherwise get `importIntoHome`, `pickFolder`, `hostCall` and
|
||||
* the clipboard. The activity answers by comparing the main frame's origin
|
||||
* against the kernel's and the one paired computer the person confirmed.
|
||||
*
|
||||
* Anything that reaches the device checks this first. The five methods that
|
||||
* do not — version, systemTheme, safeArea, setLayerOpen, setRunning — report
|
||||
* back what the page already knows about its own window and grant nothing.
|
||||
*/
|
||||
var trusted: () -> Boolean = { false }
|
||||
|
||||
/** The version the page reads to decide whether this bridge is the one it
|
||||
* knows. Bumped when a method's contract changes, not when one is added. */
|
||||
@JavascriptInterface
|
||||
fun version(): String = BRIDGE_VERSION
|
||||
|
||||
/**
|
||||
* Copy a file the person picks into the kernel's home, answering with its
|
||||
* path there — or "" when they dismissed the picker.
|
||||
*
|
||||
* `kind` names the directory inside the home, because that is a fact about
|
||||
* the kernel's layout and not about the file: an SSH private key belongs in
|
||||
* `.ssh`, and a dotenv the kernel reads for passphrases belongs at the root
|
||||
* of the home where `config.Load` looks for it.
|
||||
*
|
||||
* The path that comes back is *inside the home*, never the document
|
||||
* provider's URI. That is the point: the kernel is a child process with no
|
||||
* access to the provider, so a `content://` would be a path it cannot open.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun importIntoHome(kind: String, name: String): String {
|
||||
if (!trusted()) return ""
|
||||
val dir = when (kind) {
|
||||
"identity" -> File(kernel.homeDir(), ".ssh")
|
||||
"env" -> kernel.homeDir()
|
||||
else -> return ""
|
||||
}
|
||||
val safeName = sanitise(name)
|
||||
if (safeName.isEmpty()) return ""
|
||||
|
||||
val picked = pickFile() ?: return ""
|
||||
return try {
|
||||
dir.mkdirs()
|
||||
val target = File(dir, safeName)
|
||||
context.contentResolver.openInputStream(picked)?.use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return ""
|
||||
// A private key with group or world read is one OpenSSH refuses to
|
||||
// use, and the refusal reads as "bad permissions" from a long way
|
||||
// away. Setting it here is what makes the file usable at all.
|
||||
if (kind == "identity") {
|
||||
runCatching {
|
||||
target.setReadable(false, false)
|
||||
target.setReadable(true, true)
|
||||
target.setWritable(false, false)
|
||||
target.setWritable(true, true)
|
||||
}
|
||||
}
|
||||
target.absolutePath
|
||||
} catch (_: Exception) {
|
||||
// A copy that failed leaves nothing behind; the caller sees "" and
|
||||
// the person can try again.
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the device's browser.
|
||||
*
|
||||
* The page asks for this when a link points somewhere outside the kernel —
|
||||
* a provider's console, a documentation page — and loading it in place would
|
||||
* strand the person in a window with no address bar and no way back.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
if (!trusted()) return
|
||||
// Only what a browser can open. A `file://` or `content://` here would
|
||||
// be the page reaching into the device through a method named for
|
||||
// leaving it.
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) return
|
||||
onOpen(url)
|
||||
}
|
||||
|
||||
/** Put text on the clipboard. A WebView's own `navigator.clipboard` write is
|
||||
* gated behind a permission Android grants only to a focused document, so
|
||||
* the one thing the page actually offers to copy — a token, a command, a
|
||||
* path — has no other way out.
|
||||
*
|
||||
* Answers whether the write landed. A platform that refuses it raises
|
||||
* rather than answering, and an exception must not cross
|
||||
* `@JavascriptInterface` as a silent success — the page would say 已复制
|
||||
* over an empty clipboard. Caught here, the answer is the one the caller
|
||||
* can act on: false, and the button says 复制失败 instead. */
|
||||
@JavascriptInterface
|
||||
fun copyText(text: String): Boolean = if (!trusted()) false else runCatching {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("reasonix", text))
|
||||
}.isSuccess
|
||||
|
||||
/**
|
||||
* Write text where the person picks, and answer with the path it went to.
|
||||
*
|
||||
* The page's own route to a file — a blob URL on an `<a download>` — reaches
|
||||
* nothing here: a WebView has no downloads shelf, and its `DownloadListener`
|
||||
* fires only for a real navigation, not for a blob the page just built. So
|
||||
* the text crosses the bridge and the shell opens the system's document
|
||||
* picker, which is also the only way to reach a folder the person can find
|
||||
* again afterwards.
|
||||
*
|
||||
* "" is a dismissal, the same answer `importIntoHome` gives, and the page
|
||||
* reads the two the same way.
|
||||
*/
|
||||
/**
|
||||
* Put a copy of a file where a phone's own file manager will show it.
|
||||
*
|
||||
* `saveText` asks the person where to put it, which is the right question
|
||||
* on a desktop and the wrong one here: the kernel's workspace is
|
||||
* app-private storage, so the file someone wants is in a place no file
|
||||
* manager can open, and a picker that starts at "recent" is another place
|
||||
* to lose it. This answers the actual question — "where is it" — by
|
||||
* putting it in Downloads, in a folder named after the app, under a name
|
||||
* that is the file's own.
|
||||
*
|
||||
* The answer is the path relative to shared storage, so the page can say
|
||||
* where the file went rather than only that it worked. "" means the write
|
||||
* failed or the platform refused it.
|
||||
*
|
||||
* MediaStore on Android 10 and later, a plain file before that. The two
|
||||
* are not interchangeable: from 10 the app may not write another app's
|
||||
* files directly, and MediaStore is the only way in; before it there is no
|
||||
* MediaStore collection for downloads and the public directory is writable
|
||||
* with the storage permission this app does not ask for, so that branch is
|
||||
* best-effort and says so by answering "".
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun exportToDownloads(name: String, content: String): String {
|
||||
if (!trusted()) return ""
|
||||
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
|
||||
return exportFile(safeName, content).orEmpty()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun saveText(name: String, content: String): String {
|
||||
if (!trusted()) return ""
|
||||
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
|
||||
return saveFile(safeName, content) ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand text to whatever the device shares with — a chat app, the mail
|
||||
* client, a note app.
|
||||
*
|
||||
* The share sheet rather than a second save dialog: "where is that file"
|
||||
* is the question this answers, and on a phone a document someone wants to
|
||||
* keep or send usually goes through one of those apps rather than a folder.
|
||||
*
|
||||
* Shared as text with the filename as its subject, which is what every
|
||||
* target understands. A file attachment would need a `FileProvider` and a
|
||||
* cache copy for the same words to arrive.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun shareText(name: String, content: String): Boolean {
|
||||
if (!trusted()) return false
|
||||
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
|
||||
val posted = java.util.concurrent.CountDownLatch(1)
|
||||
var ok = false
|
||||
main.post {
|
||||
ok = runCatching { askForShare(safeName, content) }.getOrDefault(false)
|
||||
posted.countDown()
|
||||
}
|
||||
runCatching { posted.await(SHARE_TIMEOUT_SECONDS, TimeUnit.SECONDS) }
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's own light/dark answer.
|
||||
*
|
||||
* The page resolves its theme from `localStorage` first and only asks the
|
||||
* system when nothing is saved, so this is what makes a fresh install match
|
||||
* the phone instead of defaulting to light.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun systemTheme(): String {
|
||||
val night = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
return if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's real safe-area insets, in CSS pixels, as
|
||||
* `top,right,bottom,left`.
|
||||
*
|
||||
* A WebView does not implement `env(safe-area-inset-*)`: those are an iOS
|
||||
* Safari feature, and on Android every one of them resolves to `0`. So a
|
||||
* page that pads itself with them — which `mobile.css` does — is un-padded
|
||||
* on exactly the platform this shell exists for, and the first line of the
|
||||
* interface sits under the status bar.
|
||||
*
|
||||
* The values have to come from here for a second reason too: from Android 15
|
||||
* the window is edge-to-edge whether the app asks for it or not, so the bars
|
||||
* really are drawn over the page and there is no window-manager switch that
|
||||
* could move the content back down.
|
||||
*
|
||||
* Answered as a string because that is what crosses the bridge; the page
|
||||
* sets it as a CSS custom property and `mobile.css` reads it.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun safeArea(): String {
|
||||
val insets = onInsets?.invoke() ?: return "0,0,0,0"
|
||||
return "${insets.left},${insets.top},${insets.right},${insets.bottom}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pairing code a computer is showing.
|
||||
*
|
||||
* A phone cannot type what a computer renders as a QR code — it is a
|
||||
* one-time code inside a URL, and the whole reason it is a picture is that
|
||||
* nobody reads one aloud. So the page asks for the camera and gets the text
|
||||
* back, which is the same direction the computer's own dialog describes.
|
||||
*
|
||||
* The answer is the code's own text, not a parsed result: what a pairing
|
||||
* fragment means is the page's to decide, and it already knows how — the
|
||||
* `#pair=` handling ships with the interface.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun scanPairingCode(): String = if (!trusted()) "" else scanWithCamera()
|
||||
|
||||
/**
|
||||
* Load a URL in this window instead of handing it to the browser.
|
||||
*
|
||||
* The page asks for this once, right after a scan: the code it read points at
|
||||
* the computer's own Studio, and the whole point of scanning is to end up
|
||||
* looking at that computer. Going out to a browser would leave the app on one
|
||||
* screen and the paired machine on another, with the back gesture an exit
|
||||
* rather than a return.
|
||||
*
|
||||
* Only http and https are accepted. The page is this repository's own build
|
||||
* and is trusted, but a scanner reads whatever is held in front of it, so the
|
||||
* one value that arrives here from outside is checked: a `file://` or
|
||||
* `javascript:` that happened to be on a printed code must not become a
|
||||
* navigation.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
if (!trusted()) return
|
||||
val trimmed = url.trim()
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return
|
||||
main.post { onOpenInApp?.invoke(trimmed) }
|
||||
}
|
||||
|
||||
/** How the activity loads a URL in the window it owns. Set by the activity. */
|
||||
var onOpenInApp: ((String) -> Unit)? = null
|
||||
|
||||
// ---- the native browser view host bridge ----
|
||||
//
|
||||
// The page's `host.ts` looks for `window.reasonixHost` with Promise-based
|
||||
// methods (showBrowserView, hideBrowserView, etc.). `@JavascriptInterface`
|
||||
// is synchronous, so the round trip is: JS generates an id, calls
|
||||
// `hostCall(id, method, args)`, Kotlin does the work asynchronously, then
|
||||
// pushes the result back via `evaluateJavascript` calling
|
||||
// `window.__reasonixHostReply(id, json)`. The injected script (in
|
||||
// MainActivity) wires each Promise to that reply channel.
|
||||
|
||||
/** How the activity handles an async host call. Set by the activity. */
|
||||
var onHostCall: ((id: String, method: String, args: String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Entry point for the page's `window.reasonixHost` Promise methods.
|
||||
*
|
||||
* `id` is a unique call identifier the JS side generated; `method` is the
|
||||
* method name (e.g. "showBrowserView"); `args` is the JSON-encoded argument
|
||||
* array. The activity handles the call asynchronously and replies through
|
||||
* `evaluateJavascript` with `window.__reasonixHostReply(id, resultJson)`.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun hostCall(id: String, method: String, args: String) {
|
||||
if (!trusted()) return
|
||||
onHostCall?.invoke(id, method, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page has a panel up that the back gesture should close first.
|
||||
*
|
||||
* Written by the page, read by the activity's back handler. The page owns the
|
||||
* answer — it is the only thing that knows what is open, and a second
|
||||
* implementation of that question in the shell would be one that drifts — so
|
||||
* the page reports the fact and the shell keeps the action.
|
||||
*
|
||||
* Volatile because the two ends are on different threads: the page sets it
|
||||
* from the WebView's JavaScript thread, the back gesture reads it on the main
|
||||
* one, and a stale read here is a panel that does not close.
|
||||
*/
|
||||
@Volatile
|
||||
var layerOpen: Boolean = false
|
||||
private set
|
||||
|
||||
@JavascriptInterface
|
||||
fun setLayerOpen(open: Boolean) {
|
||||
layerOpen = open
|
||||
}
|
||||
|
||||
// ---- the picker, which is an Activity result in a synchronous world ----
|
||||
|
||||
private val pending = AtomicReference<Uri?>(null)
|
||||
private val latch = AtomicReference<CountDownLatch?>(null)
|
||||
|
||||
/** The code the camera read, held between the activity's answer and the
|
||||
* wait below — the same round trip the file picker takes. */
|
||||
private val scanned = AtomicReference<String?>(null)
|
||||
private val scanLatch = AtomicReference<CountDownLatch?>(null)
|
||||
|
||||
/** The main thread, because starting an Activity result must happen there. */
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* How to ask for a file. Set by the activity, which owns the Activity Result
|
||||
* API; it answers whether the ask was dispatched, not whether the person
|
||||
* picked anything — that arrives through `deliver`.
|
||||
*/
|
||||
var askForFile: () -> Boolean = { false }
|
||||
|
||||
/**
|
||||
* How the activity reports the window's insets. Set by the activity, which is
|
||||
* the only thing that can see them, and asked on every call rather than
|
||||
* cached — a rotation, a fold, or the keyboard opening all change them, and a
|
||||
* stale answer here is a stripe of interface under the status bar.
|
||||
*/
|
||||
var onInsets: (() -> Insets?)? = null
|
||||
|
||||
/**
|
||||
* How to open the camera. Set by the activity, which owns the Activity
|
||||
* Result API; like `askForFile` it answers whether the ask was dispatched,
|
||||
* and the code itself arrives through `deliverScan`.
|
||||
*/
|
||||
var askForScan: () -> Boolean = { false }
|
||||
|
||||
/**
|
||||
* How to put a file where the person picks. Set by the activity, which owns
|
||||
* the Activity Result API; it answers whether the ask was dispatched, and
|
||||
* the chosen place arrives through `deliverSave`.
|
||||
*/
|
||||
var askForSave: (name: String, content: String) -> Boolean = { _, _ -> false }
|
||||
|
||||
/**
|
||||
* How to open the share sheet. Set by the activity, which is the only thing
|
||||
* that can start an Intent; it answers whether anything on the device took
|
||||
* the text.
|
||||
*/
|
||||
var askForShare: (name: String, content: String) -> Boolean = { _, _ -> false }
|
||||
|
||||
/** The place the save dialog answered with, held between the two halves. */
|
||||
private val saveTarget = AtomicReference<Uri?>(null)
|
||||
private val saveLatch = AtomicReference<CountDownLatch?>(null)
|
||||
|
||||
/**
|
||||
* Ask where to put a file and write it there.
|
||||
*
|
||||
* The same latch round trip as `pickFile`, and for the same reason:
|
||||
* `@JavascriptInterface` cannot suspend, so the WebView's thread waits while
|
||||
* the picker is open — a modal panel the person is looking at.
|
||||
*/
|
||||
private fun saveFile(name: String, content: String): String? {
|
||||
val gate = CountDownLatch(1)
|
||||
saveLatch.set(gate)
|
||||
saveTarget.set(null)
|
||||
|
||||
val posted = CountDownLatch(1)
|
||||
var dispatched = false
|
||||
main.post {
|
||||
dispatched = runCatching { askForSave(name, content) }.getOrDefault(false)
|
||||
posted.countDown()
|
||||
}
|
||||
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
|
||||
if (!ready || !dispatched) {
|
||||
saveLatch.set(null)
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null
|
||||
else saveTarget.get()?.toString()
|
||||
} catch (_: InterruptedException) {
|
||||
null
|
||||
} finally {
|
||||
saveLatch.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the activity once the bytes are written. A null uri is a dismissal. */
|
||||
fun deliverSave(uri: Uri?) {
|
||||
saveTarget.set(uri)
|
||||
saveLatch.get()?.countDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pairing code a computer is showing, and return it.
|
||||
*
|
||||
* The same shape as `pickFile` and for the same reason: a
|
||||
* `@JavascriptInterface` method is called on the WebView's thread and must
|
||||
* return a value, so the camera is opened on the main thread and this one
|
||||
* waits. The block lasts as long as the person is aiming, which is why the
|
||||
* timeout is generous — a scan that gave up after a few seconds would be
|
||||
* one nobody could complete.
|
||||
*
|
||||
* An empty string is "no code", which is what a refusal, a cancellation and
|
||||
* a timeout all are. The caller acts on the text, so a distinct error would
|
||||
* only be a fourth way to say the same thing.
|
||||
*/
|
||||
private fun scanWithCamera(): String {
|
||||
val gate = CountDownLatch(1)
|
||||
scanLatch.set(gate)
|
||||
scanned.set(null)
|
||||
|
||||
val posted = CountDownLatch(1)
|
||||
var dispatched = false
|
||||
main.post {
|
||||
dispatched = runCatching { askForScan() }.getOrDefault(false)
|
||||
posted.countDown()
|
||||
}
|
||||
val ready = runCatching { posted.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
|
||||
if (!ready || !dispatched) {
|
||||
scanLatch.set(null)
|
||||
return ""
|
||||
}
|
||||
return try {
|
||||
if (!gate.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else scanned.get().orEmpty()
|
||||
} catch (_: InterruptedException) {
|
||||
""
|
||||
} finally {
|
||||
scanLatch.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the activity when the scanner answers. Null is a dismissal. */
|
||||
fun deliverScan(text: String?) {
|
||||
scanned.set(text)
|
||||
scanLatch.get()?.countDown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for one file and wait for the answer.
|
||||
*
|
||||
* `@JavascriptInterface` methods cannot suspend: the WebView calls them on
|
||||
* its own thread and expects a value back. So the ask is posted to the main
|
||||
* thread and this one waits on a latch. The cost is a blocked JS thread for
|
||||
* as long as the picker is open — which is a modal panel the person is
|
||||
* looking at, with nothing else in the page running.
|
||||
*
|
||||
* A timeout is set rather than waiting forever: a picker that never answers
|
||||
* (the activity destroyed underneath it) would otherwise pin a WebView
|
||||
* thread until the process died.
|
||||
*/
|
||||
private fun pickFile(): Uri? {
|
||||
val gate = CountDownLatch(1)
|
||||
latch.set(gate)
|
||||
pending.set(null)
|
||||
|
||||
val posted = CountDownLatch(1)
|
||||
var dispatched = false
|
||||
main.post {
|
||||
dispatched = runCatching { askForFile() }.getOrDefault(false)
|
||||
posted.countDown()
|
||||
}
|
||||
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
|
||||
if (!ready || !dispatched) {
|
||||
latch.set(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null else pending.get()
|
||||
} catch (_: InterruptedException) {
|
||||
null
|
||||
} finally {
|
||||
latch.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the activity when the picker answers. A null uri is a dismissal. */
|
||||
fun deliver(uri: Uri?) {
|
||||
pending.set(uri)
|
||||
latch.get()?.countDown()
|
||||
}
|
||||
|
||||
/** The folder under Downloads that this app's exports land in. */
|
||||
private fun exportFolder(): String = "Reasonix"
|
||||
|
||||
/** The MIME type this name declares, or null when the platform does not know
|
||||
* it.
|
||||
*
|
||||
* Where the type is written, the name and the type agree, and that agreement
|
||||
* is the whole fix: `.md` is answered `text/markdown` here, so MediaStore
|
||||
* has nothing to correct and the export stays `报告.md` instead of being
|
||||
* renamed `报告.md.txt` by the `text/plain` it used to be given. Where the
|
||||
* type is unknown the field is left off, which leaves MediaStore to derive
|
||||
* the same `application/octet-stream` it would have been told — the name
|
||||
* still stands on its own, which is all this is for. */
|
||||
private fun mimeFor(name: String): String? {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
if (ext.isEmpty() || ext == name.lowercase()) return null
|
||||
return runCatching { MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun exportFile(name: String, content: String): String? = runCatching {
|
||||
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
// The type is derived here rather than declared, and it is
|
||||
// written only when the name really has an extension that maps
|
||||
// to one.
|
||||
//
|
||||
// It used to be `text/plain` for everything, and MediaStore
|
||||
// answers a name whose extension disagrees with the declared
|
||||
// type by appending its own: exporting `报告.md` landed in
|
||||
// Downloads as `报告.md.txt`, and any source file came out with
|
||||
// the wrong second extension. Deriving the type from the name
|
||||
// instead keeps the two in agreement — which is what stops the
|
||||
// rename — and a name the platform has no type for is left off
|
||||
// entirely rather than declared as `application/octet-stream`,
|
||||
// so nothing is corrected there either.
|
||||
mimeFor(name)?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
|
||||
put(
|
||||
MediaStore.Downloads.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_DOWNLOADS + "/" + exportFolder(),
|
||||
)
|
||||
// Hidden until the bytes are all there, so a file manager that
|
||||
// reads the folder mid-write cannot show half a document.
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: return@runCatching null
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return@runCatching null
|
||||
values.clear()
|
||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
"${Environment.DIRECTORY_DOWNLOADS}/${exportFolder()}/$name"
|
||||
} else {
|
||||
val dir = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
exportFolder(),
|
||||
)
|
||||
if (!dir.exists() && !dir.mkdirs()) return@runCatching null
|
||||
val target = File(dir, name)
|
||||
target.writeBytes(bytes)
|
||||
target.absolutePath
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun sanitise(name: String): String {
|
||||
// A name from the page is used as a filename, so it is reduced to one:
|
||||
// no separators, no traversal, and nothing a filesystem treats as
|
||||
// special. The default is the name the field it came from suggests.
|
||||
val trimmed = name.trim().substringAfterLast('/').substringAfterLast('\\')
|
||||
if (trimmed.isEmpty() || trimmed == "." || trimmed == "..") return ""
|
||||
return trimmed.filter { it.isLetterOrDigit() || it in "._-" }.take(64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a turn is running, so the screen can be kept awake for one.
|
||||
*
|
||||
* The page owns the answer — it is the only thing that knows if the kernel
|
||||
* is working — and the shell owns the flag, because a window flag is not
|
||||
* something a page can set. Nothing is asked at launch: a window that is
|
||||
* merely open is one the person may be reading, and holding the screen on
|
||||
* for that is holding it on for nothing.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setRunning(running: Boolean) {
|
||||
onRunning?.invoke(running)
|
||||
}
|
||||
|
||||
/** How the activity applies that flag. Set by the activity. */
|
||||
var onRunning: ((Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Whether the app may read every file, which is what lets the kernel open a
|
||||
* folder on this phone as a workspace.
|
||||
*
|
||||
* Asked rather than assumed: the permission is granted by hand in system
|
||||
* settings, so the page has to be able to say "not yet" rather than fail on
|
||||
* the first `stat`. Answered `true` below API 30, where ordinary read
|
||||
* permission is enough and the concept does not exist.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun hasAllFilesAccess(): Boolean = filesAccess?.invoke() ?: true
|
||||
|
||||
var filesAccess: (() -> Boolean)? = null
|
||||
|
||||
/** Open the system page where that permission is granted. The only place
|
||||
* Android offers it — there is no dialog for this one. */
|
||||
@JavascriptInterface
|
||||
fun requestAllFilesAccess() {
|
||||
if (!trusted()) return
|
||||
askForFilesAccess?.invoke()
|
||||
}
|
||||
|
||||
var askForFilesAccess: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Whether this app is exempt from the system's battery optimisation.
|
||||
*
|
||||
* A turn runs in a child process under a foreground service. Under doze the
|
||||
* whole app group is frozen anyway, so the turn stops making progress while
|
||||
* the screen is off — which is the state the person left it in on purpose.
|
||||
* Unrestricted is the answer, and it is a question Android answers.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun batteryUnrestricted(): Boolean = if (!trusted()) false else (batteryFree?.invoke() ?: true)
|
||||
|
||||
var batteryFree: (() -> Boolean)? = null
|
||||
|
||||
/** Ask for that exemption. Unlike the storage one, this has a real dialog. */
|
||||
@JavascriptInterface
|
||||
fun requestBatteryUnrestricted() {
|
||||
if (!trusted()) return
|
||||
askForBattery?.invoke()
|
||||
}
|
||||
|
||||
var askForBattery: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Ask for a folder on this phone and answer with its real filesystem path.
|
||||
*
|
||||
* "" is a dismissal, the same answer `importIntoHome` gives — and, unlike
|
||||
* that one, an empty string is a normal outcome here rather than a failure.
|
||||
* The `startIn` hint is a path the kernel knows; the picker may ignore it,
|
||||
* which is the picker's business.
|
||||
*
|
||||
* The round trip is the same latch as `pickFile`, for the same reason: a
|
||||
* document-tree picker is another activity, and this call cannot suspend.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun pickFolder(startIn: String): String {
|
||||
if (!trusted()) return ""
|
||||
val gate = CountDownLatch(1)
|
||||
folderLatch.set(gate)
|
||||
folderPending.set(null)
|
||||
|
||||
val posted = CountDownLatch(1)
|
||||
var dispatched = false
|
||||
main.post {
|
||||
dispatched = runCatching { askForFolder(startIn) }.getOrDefault(false)
|
||||
posted.countDown()
|
||||
}
|
||||
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
|
||||
if (!ready || !dispatched) {
|
||||
folderLatch.set(null)
|
||||
return ""
|
||||
}
|
||||
return try {
|
||||
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else folderPending.get().orEmpty()
|
||||
} catch (_: InterruptedException) {
|
||||
""
|
||||
} finally {
|
||||
folderLatch.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
var askForFolder: (String) -> Boolean = { false }
|
||||
|
||||
private val folderPending = AtomicReference<String?>(null)
|
||||
private val folderLatch = AtomicReference<CountDownLatch?>(null)
|
||||
|
||||
/** Called by the activity with the folder's real path, or null on dismissal. */
|
||||
fun deliverFolder(path: String?) {
|
||||
folderPending.set(path ?: "")
|
||||
folderLatch.get()?.countDown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BRIDGE_VERSION = "4"
|
||||
private const val PICK_TIMEOUT_SECONDS = 5L * 60
|
||||
|
||||
/** Long, because this is however long someone takes to aim. A shorter
|
||||
* one would turn "still lining it up" into a failed scan. */
|
||||
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
|
||||
|
||||
/** Only as long as the sheet takes to appear: the answer is "something
|
||||
* took it", which is known the moment the Intent resolves. */
|
||||
private const val SHARE_TIMEOUT_SECONDS = 15L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's insets, in CSS pixels.
|
||||
*
|
||||
* A named type rather than four numbers because the page reads them as one
|
||||
* thing: half an inset is a layout that is wrong in one direction, and a caller
|
||||
* that passed the wrong pair would be indistinguishable from a device with no
|
||||
* cutout at all.
|
||||
*/
|
||||
data class Insets(val left: Int, val top: Int, val right: Int, val bottom: Int)
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Reading a QR code out of one camera frame.
|
||||
*
|
||||
* ZXing core and nothing else: it is pure Java, so it runs identically on a
|
||||
* phone with Google Play Services and on one whose HarmonyOS compatibility
|
||||
* layer has none. A decoder that leaned on ML Kit would work on the machine it
|
||||
* was written on and fail on the one that needs it.
|
||||
*
|
||||
* The frame arrives as YUV_420_888, whose Y plane is already the greyscale
|
||||
* bitmap ZXing wants — no conversion, no colour maths, just the bytes. That is
|
||||
* the whole reason a phone can decode 30 frames a second without warming up.
|
||||
*/
|
||||
object Qr {
|
||||
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
// Only the form the computer actually renders. Leaving the
|
||||
// reader open to every 1D barcode makes each frame slower for
|
||||
// a case that cannot occur, and a slower frame is a code that
|
||||
// has already left the viewfinder.
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The code's text, or null when this frame holds none. */
|
||||
fun decode(image: ImageProxy): String? {
|
||||
val plane = image.planes.firstOrNull() ?: return null
|
||||
val buffer = plane.buffer
|
||||
val data = ByteArray(buffer.remaining())
|
||||
buffer.get(data)
|
||||
// `rowStride` is padded to a hardware alignment and is usually wider
|
||||
// than the picture. Passing the width as the row length is what tells
|
||||
// ZXing where each row actually ends; getting it wrong shears the image
|
||||
// and every code reads as garbage.
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data,
|
||||
plane.rowStride,
|
||||
image.height,
|
||||
0,
|
||||
0,
|
||||
image.width,
|
||||
image.height,
|
||||
false,
|
||||
)
|
||||
return runCatching {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||
}.getOrNull().also {
|
||||
// The reader keeps per-frame state between calls; resetting is what
|
||||
// stops a partial match in one frame from corrupting the next.
|
||||
reader.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package io.reasonix.mobile.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* The camera, held open just long enough to read one pairing code.
|
||||
*
|
||||
* A phone that wants to drive a computer cannot type the code that computer
|
||||
* shows: it is a base64 fragment inside a URL, and the whole point of showing it
|
||||
* as a QR code is that nobody reads one aloud. So this is the way in — the same
|
||||
* gesture the computer's own dialog asks for, from the other side.
|
||||
*
|
||||
* The decode is ZXing rather than ML Kit on purpose. ML Kit needs Google Play
|
||||
* Services, and this app installs on phones whose HarmonyOS compatibility layer
|
||||
* has none: a GMS-backed scanner would work on a developer's phone and fail on
|
||||
* the device that needs it. ZXing core is pure Java, so the scanner behaves the
|
||||
* same on both.
|
||||
*
|
||||
* It returns the first code it reads and finishes. A pairing code is
|
||||
* single-use and short-lived, so "keep scanning after a hit" is not a feature —
|
||||
* it is a way to spend a code that a later frame would have discarded.
|
||||
*/
|
||||
class ScanActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var preview: PreviewView
|
||||
private lateinit var hint: TextView
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
/** Set once a code is found, so the frames in flight behind it do not each
|
||||
* try to finish the activity. */
|
||||
@Volatile
|
||||
private var settled = false
|
||||
|
||||
private val requestCamera = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) start() else refuse()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val root = FrameLayout(this)
|
||||
preview = PreviewView(this).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
// The frames are analysed, not composed: filling the view and
|
||||
// cropping is right here and would be wrong for a camera app.
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
hint = TextView(this).apply {
|
||||
text = getString(R.string.scan_hint)
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
textSize = 14f
|
||||
gravity = Gravity.CENTER
|
||||
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad, pad, pad)
|
||||
// Readable over whatever the camera is pointing at, which is the
|
||||
// one background a design system cannot choose.
|
||||
setBackgroundColor(0x99000000.toInt())
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM,
|
||||
)
|
||||
}
|
||||
root.addView(preview)
|
||||
root.addView(hint)
|
||||
setContentView(root)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
start()
|
||||
} else {
|
||||
requestCamera.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refuse() {
|
||||
// No dialog of our own: the permission prompt already said no, and a
|
||||
// second window repeating it is one more tap to dismiss. The caller
|
||||
// gets the same answer as a cancelled scan.
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
|
||||
val previewUseCase = Preview.Builder().build().also { it.setSurfaceProvider(preview.surfaceProvider) }
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
// Only the newest frame matters: a stale one describes where the
|
||||
// camera was, and a code that has already left the viewfinder
|
||||
// must not win over one that is in it now.
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(executor, ::analyse) }
|
||||
runCatching {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
|
||||
}.onFailure { refuse() }
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun analyse(image: ImageProxy) {
|
||||
try {
|
||||
if (settled) return
|
||||
val text = Qr.decode(image) ?: return
|
||||
if (settled) return
|
||||
settled = true
|
||||
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
|
||||
// Finishing from the analysis thread is the whole reason `settled`
|
||||
// exists; the activity teardown has to happen on the main one.
|
||||
runOnUiThread { finish() }
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_TEXT = "text"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The mark, drawn rather than shipped as a bitmap: one R in the app's own ink
|
||||
on the palette's ground, which scales to every launcher's mask without a
|
||||
density bucket per size. The hole in the R is the glyph's own counter, cut
|
||||
with evenOdd rather than painted over. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#F4F1EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M39,30 L39,78 L47,78 L47,60 L54.5,60 L65,78 L74.5,78 L62.5,58 C68.6,55.8 72.6,50.6 72.6,43.6 C72.6,35.4 66.8,30 57.6,30 Z M47,37 L56.4,37 C61.2,37 64.4,39.4 64.4,43.8 C64.4,48.2 61.2,50.9 56.4,50.9 L47,50.9 Z" />
|
||||
<path
|
||||
android:fillColor="#A6680C"
|
||||
android:pathData="M62,66 L74,66 L74,78 L62,78 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The status-bar glyph, not the launcher icon: Android renders a notification
|
||||
icon as a mask, so anything that is not a solid silhouette on transparency
|
||||
comes out as a grey blob. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M3,4h18v2H3zM3,9h18v2H3zM3,14h12v2H3zM3,19h12v2H3zM17.5,13.2l1.6,2.6 1.6,-2.6h2.3l-2.8,4.3V21h-2.2v-3.5L15.2,13.2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The screen between the tap and the page.
|
||||
|
||||
It exists for a reason that is not cosmetic: the kernel has to be unpacked and
|
||||
started before there is a page to show, and on a cold start that is about a
|
||||
second. A blank window for a second reads as a broken app; a line of text that
|
||||
says what is happening does not.
|
||||
|
||||
The progress bar is indeterminate and driven by nothing: the WebView's own
|
||||
progress is mirrored onto it while the page loads, and before that it simply
|
||||
turns, which is the honest description of "we do not know how long".
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/page"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingStart="32dp"
|
||||
android:paddingEnd="32dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/splash_wordmark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="#8A857B"
|
||||
android:textSize="22sp"
|
||||
android:letterSpacing="0.08"
|
||||
android:fontFamily="sans-serif-medium" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/splash_bar"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="132dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="26dp"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/splash_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/splash_connecting"
|
||||
android:textColor="#8A857B"
|
||||
android:textSize="13sp"
|
||||
android:lineSpacingMultiplier="1.5" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/splash_retry"
|
||||
style="?android:attr/borderlessButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:minHeight="44dp"
|
||||
android:text="@string/splash_retry"
|
||||
android:textColor="#A6680C"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The adaptive launcher icon.
|
||||
|
||||
minSdk is 26, which is the release that introduced adaptive icons, so this
|
||||
is the only launcher asset the app needs: there is no device running it
|
||||
that would ask for a bitmap fallback. -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<!-- The safe zone is the middle 66dp of 108dp; the glyph is drawn inside it
|
||||
so no launcher's mask can clip it. -->
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Reasonix</string>
|
||||
|
||||
<string name="channel_kernel">Kernel running</string>
|
||||
<string name="channel_kernel_desc">Shown while the Reasonix kernel runs in the background. Can be muted on its own in system settings.</string>
|
||||
|
||||
<string name="notify_running_title">Reasonix is running</string>
|
||||
<string name="notify_running_body">A task is still going. Switch back to see it.</string>
|
||||
<string name="notify_failed_title">Reasonix could not start</string>
|
||||
|
||||
<string name="err_kernel_unreachable">Could not reach the kernel. Reopen the app from your home screen.</string>
|
||||
|
||||
<string name="download_default_name">reasonix-download</string>
|
||||
<string name="download_saved">Saved: %1$s</string>
|
||||
<string name="download_failed">Save failed: %1$s</string>
|
||||
<string name="download_failed_status">Save failed: the server answered %1$d</string>
|
||||
|
||||
<string name="splash_connecting">Starting the kernel…</string>
|
||||
<string name="splash_retry">Retry</string>
|
||||
|
||||
<string name="scan_title">Scan the code on the computer</string>
|
||||
<string name="scan_hint">Point at the QR code in the computer\'s device panel to connect to it.</string>
|
||||
<string name="scan_button">Scan to connect</string>
|
||||
<string name="scan_denied">No camera permission, so scanning is unavailable. Allow it in system settings.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The dark palette's page colour. `DayNight` on the theme picks this up when
|
||||
the system is dark, which is what keeps the frame before the WebView from
|
||||
flashing light at someone using a dark phone at night. -->
|
||||
<resources>
|
||||
<color name="page">#0E1014</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The light palette's page colour, and only what the window needs before the
|
||||
WebView paints. The interface's own colours are `tokens.css`'s and are the
|
||||
page's business; these two exist so the first frame is the app's ground
|
||||
rather than the platform default white. -->
|
||||
<resources>
|
||||
<color name="page">#F4F1EA</color>
|
||||
<color name="boot">#07080C</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The launcher ground: the interface's dark page colour. It is the same in
|
||||
both themes because a launcher icon is not themed by the app. -->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#14161C</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Every string the shell itself shows. The interface's own text belongs to
|
||||
the page and lives in its i18n bundle; these are only the few lines that
|
||||
exist before it, or when it cannot load. -->
|
||||
<resources>
|
||||
<string name="app_name">Reasonix</string>
|
||||
|
||||
<string name="channel_kernel">内核运行中</string>
|
||||
<string name="channel_kernel_desc">Reasonix 内核在后台运行时显示,可在系统设置中单独关闭。</string>
|
||||
|
||||
<string name="notify_running_title">Reasonix 正在运行</string>
|
||||
<string name="notify_running_body">任务继续进行,切回应用即可查看。</string>
|
||||
<string name="notify_failed_title">Reasonix 无法启动</string>
|
||||
|
||||
<string name="err_kernel_unreachable">连接内核失败。请返回桌面重新打开应用。</string>
|
||||
|
||||
<string name="download_default_name">reasonix-download</string>
|
||||
<string name="download_saved">已保存:%1$s</string>
|
||||
<string name="download_failed">保存失败:%1$s</string>
|
||||
<string name="download_failed_status">保存失败:服务器返回 %1$d</string>
|
||||
|
||||
<string name="splash_connecting">正在启动内核…</string>
|
||||
<string name="splash_retry">重试</string>
|
||||
|
||||
<string name="wsperm_title">需要「所有文件访问」权限</string>
|
||||
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</string>
|
||||
<string name="wsperm_go">去设置</string>
|
||||
<string name="wsperm_later">以后再说</string>
|
||||
|
||||
<string name="scan_title">扫描电脑上的二维码</string>
|
||||
<string name="scan_hint">对准电脑上「设备访问」里的二维码,识别后即可连接这台电脑。</string>
|
||||
<string name="scan_button">扫码连接电脑</string>
|
||||
<string name="scan_denied">没有相机权限,无法扫码。请在系统设置里允许。</string>
|
||||
<string name="share_title">分享文件</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The shell's own furniture: a window background matching the page's light
|
||||
palette before the WebView paints, and a transparent system bar so the
|
||||
page's own safe-area padding is what the notch meets. The page themes
|
||||
itself; this exists only so the first frame is not a white flash. -->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.Reasonix" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/page</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<!-- The page draws its own edge-to-edge layout and pads itself with the
|
||||
safe-area insets from mobile.css. Laying the window out edge to edge
|
||||
while leaving the bars transparent is what makes those insets real
|
||||
numbers instead of zero. -->
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="p">shortEdges</item>
|
||||
<item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item>
|
||||
<item name="android:enforceNavigationBarContrast" tools:targetApi="q">false</item>
|
||||
</style>
|
||||
|
||||
<!-- The camera. A black ground because the preview has not drawn yet and a
|
||||
light window behind a dark viewfinder is a flash in the dark; the bars
|
||||
stay transparent so the preview reaches the edges, which is what makes
|
||||
a code near the top of the screen scannable. -->
|
||||
<style name="Theme.Reasonix.Scan" parent="Theme.Reasonix">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Backups are off, and this file says why rather than leaving it implicit.
|
||||
|
||||
A Reasonix home holds provider keys, an account token and every session
|
||||
transcript. Android's auto-backup would copy all of it to the person's cloud
|
||||
drive and, on a device-to-device transfer, to a new phone — which is a
|
||||
credential leaving the device by a route nobody chose. `allowBackup="false"`
|
||||
in the manifest turns that off; these two stanzas are the belt to its braces,
|
||||
excluding everything explicitly so that a later change to `allowBackup` cannot
|
||||
quietly start copying credentials.
|
||||
|
||||
What a person wants to move between phones, the kernel already moves: its own
|
||||
export writes a file the person hands to the system's picker.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Cleartext HTTP is permitted, and the platform has no way to permit less.
|
||||
|
||||
Two kinds of page load over plain HTTP: the kernel's own page on
|
||||
`127.0.0.1:<port>`, and the same page served by a paired computer on whatever
|
||||
address its router handed out. The port is chosen when the kernel starts and
|
||||
the LAN address when a code is scanned, so neither can be written here — and a
|
||||
`domain-config` matches literals and domains, never a subnet.
|
||||
|
||||
So the boundary is not this file. `MainActivity` decides which cleartext origin
|
||||
may become the window: `shouldOverrideUrlLoading` sends every http(s) origin
|
||||
that is not the kernel's or the one just paired out to the browser, and the
|
||||
native bridge answers only those two. A plaintext page on the LAN therefore
|
||||
cannot become the document holding the bridge, which is what the reviewer's
|
||||
"deliberate path for LAN pairing" asks for.
|
||||
|
||||
HTTPS is untouched: nothing here downgrades a request, and `trust-anchors`
|
||||
keeps the system roots for every TLS connection that is made.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,15 @@
|
||||
// The build itself lives in app/build.gradle; this file declares the plugin
|
||||
// versions once so the app module can be read on its own.
|
||||
//
|
||||
// AGP 8.11.1 is the pairing for the Gradle 8.14 toolchain this machine has:
|
||||
// AGP 8.7 and older refuse a Gradle that new, and AGP 8.13+ wants a Gradle this
|
||||
// one does not have. Both numbers are pinned rather than ranged, because a
|
||||
// build that silently moved either one would be a different build.
|
||||
plugins {
|
||||
id "com.android.application" version "8.11.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# AndroidX is the only copy of the support libraries this project uses; the
|
||||
# non-transitive R class keeps the app's own resources from aliasing library
|
||||
# ones, which is what makes `R.string.*` unambiguous here.
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# The daemon is what keeps a second `gradle assembleDebug` from re-reading the
|
||||
# whole SDK, and the heap is sized for the R8 pass over the WebView and the
|
||||
# packaged page rather than for this handful of Kotlin files.
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
kotlin.code.style=official
|
||||
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
# Pinning the distribution's checksum is what makes the download verifiable: a
|
||||
# mirror that served a different zip would fail here instead of being unpacked
|
||||
# and run. The value is Gradle's own, from
|
||||
# https://services.gradle.org/distributions/gradle-8.14.3-bin.zip.sha256
|
||||
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the Reasonix kernel for Android and place it where the APK packager
|
||||
# expects a native library.
|
||||
#
|
||||
# Why a shell script and not a Gradle task: the kernel is a Go module that lives
|
||||
# outside this Gradle build, with its own toolchain version pinned in go.mod. A
|
||||
# Gradle task that invoked `go build` would make the Android build depend on a Go
|
||||
# toolchain being configured for it, and would rebuild a 21MB binary on every
|
||||
# `assembleDebug` even when nothing in the kernel changed. This runs when you
|
||||
# mean it to, and the APK task only checks that the result is there.
|
||||
#
|
||||
# Output: app/src/main/jniLibs/arm64-v8a/libreasonix.so
|
||||
# The name is not cosmetic. Android extracts files under `jniLibs/` to
|
||||
# `nativeLibraryDir` and permits execution *there*; a binary shipped in
|
||||
# `assets/` would have to be copied to app storage and chmod'd, which fails on
|
||||
# a device whose data partition is mounted noexec. `lib*.so` is the shape the
|
||||
# packager recognises as a native library, so the kernel wears it.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
android="$here/.."
|
||||
upstream="$android/.."
|
||||
out="$android/app/src/main/jniLibs/arm64-v8a/libreasonix.so"
|
||||
|
||||
if [ ! -d "$upstream" ]; then
|
||||
echo "kernel source not found: $upstream" >&2
|
||||
echo "run this from a checkout: the parent of android/ must be the kernel source." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GOOS=android needs nothing else: the kernel is CGO-free, which is what makes a
|
||||
# single static binary possible in the first place. `-s -w` drops the symbol and
|
||||
# DWARF tables — about a third of the file — and nothing on a phone reads them.
|
||||
export GOOS=android
|
||||
export GOARCH=arm64
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# The app version, read from the module's own build.gradle so the two cannot
|
||||
# drift. The official build stamps these through `-X` (see upstream's Makefile);
|
||||
# without them the kernel reports `version=dev revision=unknown`, which a phone
|
||||
# then shows next to the documentation it was built with and makes a bug report
|
||||
# from that build impossible to place. There is no `.git` here, so the revision
|
||||
# names the package the corpus shipped in rather than a commit hash — the honest
|
||||
# answer when there is no commit to name.
|
||||
app_gradle="$android/app/build.gradle"
|
||||
version_field() {
|
||||
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
|
||||
}
|
||||
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
|
||||
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
ldflags="-s -w"
|
||||
ldflags="$ldflags -X main.version=v$app_version"
|
||||
ldflags="$ldflags -X main.buildTimeUTC=$build_time_utc"
|
||||
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedVersion=v$app_version"
|
||||
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedRevision=apk-v$app_version"
|
||||
|
||||
echo "building the kernel (android/arm64, v$app_version) ..."
|
||||
mkdir -p "$(dirname "$out")"
|
||||
cd "$upstream"
|
||||
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
|
||||
|
||||
size=$(wc -c < "$out")
|
||||
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One command from a clean checkout to an installable APK.
|
||||
#
|
||||
# The order matters and is the whole content of this script: the kernel and the
|
||||
# page are inputs to packaging, not build steps inside it, so they are produced
|
||||
# first and the Gradle task only assembles what is already there. Running
|
||||
# `gradle assembleRelease` directly on a fresh checkout fails on purpose (see
|
||||
# `requireBuildInputs` in app/build.gradle) rather than producing an APK with no
|
||||
# kernel in it.
|
||||
#
|
||||
# Usage: ./scripts/build.sh [debug|release] (default: release)
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
android="$here/.."
|
||||
variant="${1:-release}"
|
||||
|
||||
case "$variant" in
|
||||
debug|release) ;;
|
||||
*) echo "usage: $0 [debug|release]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
"$here/build-kernel.sh"
|
||||
"$here/sync-page.sh"
|
||||
|
||||
cd "$android"
|
||||
|
||||
# The wrapper is committed, jar and all, and it is the only thing that runs the
|
||||
# build. Generating one here would tie the dependency versions to whichever
|
||||
# `gradle` happened to be on PATH, which is the opposite of a pinned build.
|
||||
if [ ! -f gradlew ]; then
|
||||
echo "gradlew is missing: this repository commits the Gradle wrapper, so the checkout is incomplete" >&2
|
||||
echo "(a release build does not use `gradle` from PATH: that would tie dependency versions to the machine)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
task="assemble$([ "$variant" = release ] && echo Release || echo Debug)"
|
||||
echo "packaging the APK ($variant) ..."
|
||||
./gradlew "$task" --no-daemon
|
||||
|
||||
apk_dir="$android/app/build/outputs/apk/$variant"
|
||||
echo
|
||||
echo "done. Installable artifacts:"
|
||||
find "$apk_dir" -name '*.apk' -maxdepth 1 -print -exec ls -lh {} \;
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the built APK on a connected phone and show its log.
|
||||
#
|
||||
# `adb` comes from the Android SDK rather than this project, because the SDK is
|
||||
# already how the app is built. Point ANDROID_HOME at it, or have `adb` on PATH.
|
||||
#
|
||||
# Usage: ./scripts/run.sh [debug|release]
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
android="$here/.."
|
||||
variant="${1:-release}"
|
||||
|
||||
adb="${ADB:-${ANDROID_HOME:-$HOME/AndroidSDK}/platform-tools/adb}"
|
||||
if ! command -v "$adb" >/dev/null 2>&1; then
|
||||
adb="adb"
|
||||
fi
|
||||
|
||||
apk_dir="$android/app/build/outputs/apk/$variant"
|
||||
apk=$(find "$apk_dir" -name '*.apk' -maxdepth 1 2>/dev/null | head -n1)
|
||||
if [ -z "$apk" ]; then
|
||||
echo "no $variant APK found; run ./scripts/build.sh $variant first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "installing $apk ..."
|
||||
"$adb" install -r "$apk"
|
||||
|
||||
# The device count is checked before the app is started so an emulator-less
|
||||
# machine gets "no devices" rather than a launch failure that reads like a bug.
|
||||
if [ "$("$adb" devices | grep -c 'device$')" -eq 0 ]; then
|
||||
echo "no device attached; skipping the launch." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
package="io.reasonix.mobile.app"
|
||||
[ "$variant" = debug ] && package="io.reasonix.mobile.app.debug"
|
||||
"$adb" shell monkey -p "$package" -c android.intent.category.LAUNCHER 1 >/dev/null
|
||||
|
||||
echo
|
||||
echo "following the log (Ctrl-C to stop) ..."
|
||||
"$adb" logcat -v color "ReasonixKernel:V" "AndroidRuntime:E" "*:S"
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the interface and copy it where the APK will carry it.
|
||||
#
|
||||
# The page that ships is the same build the desktop Studio serves — that is the
|
||||
# whole reason the mobile layout lives in `desktop/frontend-next` rather than in
|
||||
# an Android project's own web root. Nothing is edited on the way through: the
|
||||
# only thing this script does that `pnpm build` does not is copy `dist/` into the
|
||||
# app's assets.
|
||||
#
|
||||
# Output: app/src/main/assets/page/
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
android="$here/.."
|
||||
frontend="$android/../desktop/frontend-next"
|
||||
dest="$android/app/src/main/assets/page"
|
||||
|
||||
if [ ! -d "$frontend" ]; then
|
||||
echo "frontend project not found: $frontend" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$frontend"
|
||||
|
||||
# pnpm is the package manager the lockfile is written for; npm would resolve a
|
||||
# different tree than the one that was tested.
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm 10 is required: npm install -g pnpm@10" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "installing frontend dependencies ..."
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
echo "building the frontend ..."
|
||||
pnpm build
|
||||
|
||||
rm -rf "$dest"
|
||||
mkdir -p "$dest"
|
||||
cp -R dist/. "$dest/"
|
||||
|
||||
if [ ! -f "$dest/index.html" ]; then
|
||||
echo "the build produced no index.html; the frontend build probably failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count=$(find "$dest" -type f | wc -l)
|
||||
size=$(du -sk "$dest" | cut -f1)
|
||||
echo "page ready: $dest ($count files, $((size / 1024)) MiB)"
|
||||
|
||||
# MIT requires the copyright notice and the licence text to travel with a binary
|
||||
# distribution, so the APK carries it. Generated from the repository's own
|
||||
# `LICENSE` rather than committed a second time: a checked-in copy is one more
|
||||
# thing that drifts when the root file is updated.
|
||||
root_license="$android/../LICENSE"
|
||||
if [ ! -f "$root_license" ]; then
|
||||
echo "LICENSE not found: $root_license" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$root_license" "$android/app/src/main/assets/LICENSE"
|
||||
echo "licence: app/src/main/assets/LICENSE (copied from the repository's LICENSE)"
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
|
||||
# actually packaged into the APK.
|
||||
#
|
||||
# This is the closest thing to an end-to-end test that runs without a phone: the
|
||||
# launch flags, the token file, the port file and the `--page` directory are the
|
||||
# same ones the Kotlin shell passes, and the page is read out of
|
||||
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
|
||||
# serves a working interface, the only untested part is Android itself.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
android="$here/.."
|
||||
page="$android/app/src/main/assets/page"
|
||||
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
|
||||
|
||||
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
|
||||
rm -rf "$work"
|
||||
mkdir -p "$work/home/remote"
|
||||
|
||||
if [ ! -f "$kernel" ]; then
|
||||
echo "no runnable kernel; build one first:" >&2
|
||||
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$page/index.html" ]; then
|
||||
echo "no packaged page: $page/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
|
||||
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
|
||||
printf '%s\n' "$token" > "$work/home/remote/launch.token"
|
||||
|
||||
export REASONIX_HOME="$work/home"
|
||||
export HOME="$work/home"
|
||||
export TMPDIR="$work"
|
||||
|
||||
echo "kernel: $kernel"
|
||||
echo "page: $page"
|
||||
echo "token: ${token:0:8}... (${#token} chars)"
|
||||
|
||||
"$kernel" serve \
|
||||
--addr 127.0.0.1:0 \
|
||||
--auth token \
|
||||
--token-file "$work/home/remote/launch.token" \
|
||||
--port-file "$work/home/remote/serve.port" \
|
||||
--pid-file "$work/home/remote/serve.pid" \
|
||||
--no-open \
|
||||
-page "$page" \
|
||||
> "$work/kernel.log" 2>&1 &
|
||||
echo $! > "$work/kernel.pid"
|
||||
|
||||
# Wait for the port file the kernel writes after binding, which is exactly the
|
||||
# wait `Kernel.awaitPort` performs.
|
||||
for _ in $(seq 1 150); do
|
||||
if [ -s "$work/home/remote/serve.port" ]; then break; fi
|
||||
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
|
||||
echo "the kernel exited; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ ! -s "$work/home/remote/serve.port" ]; then
|
||||
echo "the kernel reported no listening address; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
port=$(cat "$work/home/remote/serve.port")
|
||||
echo "listening on: $port"
|
||||
|
||||
# The two things the WebView will do: fetch the page, and exist on the origin.
|
||||
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
|
||||
echo "GET / -> $probe"
|
||||
if [ "$probe" != "200" ]; then
|
||||
echo "the page did not answer; log:" >&2
|
||||
cat "$work/kernel.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$work/entry.txt" <<EOF
|
||||
origin=http://$port
|
||||
entry=http://$port/#token=$token
|
||||
token=$token
|
||||
EOF
|
||||
|
||||
echo "entry written: $work/entry.txt"
|
||||
echo "kernel log (first 10 lines):"
|
||||
head -10 "$work/kernel.log"
|
||||
@@ -0,0 +1,31 @@
|
||||
// Reasonix Mobile — the Android shell around the Reasonix kernel.
|
||||
//
|
||||
// The shell is deliberately thin: it unpacks the kernel and the built page that
|
||||
// ship inside the APK, starts `reasonix serve` on a loopback port, and points a
|
||||
// WebView at it. Everything the person sees and does is the same page the
|
||||
// desktop Studio and `reasonix web` serve, because it is literally the same
|
||||
// build of `desktop/frontend-next` — which is what keeps one interface honest
|
||||
// across the three surfaces, and why the page is packaged rather than fetched.
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
// The official repositories only. A mirror listed here would be an
|
||||
// unverified trust anchor for anything that ends up in a signed build:
|
||||
// the artifact it serves is whichever copy landed there. A machine that
|
||||
// needs a mirror belongs on a user-level init script, where the choice
|
||||
// stays with the person building rather than travelling with the source.
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "ReasonixMobile"
|
||||
include(":app")
|
||||
@@ -24,7 +24,7 @@ func validateAnswerIsolation(tasks []task) error {
|
||||
if t.answerRoot == "" {
|
||||
continue
|
||||
}
|
||||
if !sandbox.Available() {
|
||||
if !sandbox.ConfinesByDefault() {
|
||||
return fmt.Errorf("%s contains answer material, but this host has no OS read sandbox", t.answerRoot)
|
||||
}
|
||||
roots, err := answerReadRoots(t.answerRoot)
|
||||
|
||||
@@ -26,13 +26,13 @@ func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
err = validateAnswerIsolation(tasks)
|
||||
if sandbox.Available() && err != nil {
|
||||
if sandbox.ConfinesByDefault() && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sandbox.Available() && err == nil {
|
||||
if !sandbox.ConfinesByDefault() && err == nil {
|
||||
t.Fatal("MemoryBench accepted a host with no OS read sandbox")
|
||||
}
|
||||
if sandbox.Available() {
|
||||
if sandbox.ConfinesByDefault() {
|
||||
previous := benchStateBase
|
||||
benchStateBase = func() (string, error) { return filepath.Join(tasks[0].answerRoot, "cache"), nil }
|
||||
t.Cleanup(func() { benchStateBase = previous })
|
||||
@@ -43,7 +43,7 @@ func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
|
||||
if !sandbox.Available() {
|
||||
if !sandbox.ConfinesByDefault() {
|
||||
t.Skip("this host has no OS read sandbox")
|
||||
}
|
||||
const suite = "../../benchmarks/memorybench"
|
||||
@@ -282,7 +282,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
|
||||
read, err := auditAnswerTrajectories(dir, "demo", task{ID: "demo", answerRoot: root}, 2)
|
||||
if err != nil || !read {
|
||||
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func scanAnswerRead(path string, bodyLines []string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
|
||||
func auditAnswerTrajectories(dir, stem string, t task, segments int) (bool, error) {
|
||||
if segments < 1 {
|
||||
return false, fmt.Errorf("run recorded no segments")
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
for index := 1; index <= segments; index++ {
|
||||
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
|
||||
path := segmentTrajectoryPath(dir, stem, segment{index: index}, segments)
|
||||
read, err := scanAnswerRead(path, lines)
|
||||
if err != nil || read {
|
||||
return read, err
|
||||
@@ -170,11 +170,11 @@ func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
func auditAnswerRun(r *result, dir string, t task) bool {
|
||||
func auditAnswerRun(r *result, dir, stem string, t task) bool {
|
||||
if t.answerRoot == "" {
|
||||
return true
|
||||
}
|
||||
read, err := auditAnswerTrajectories(dir, t, r.Segments)
|
||||
read, err := auditAnswerTrajectories(dir, stem, t, r.Segments)
|
||||
if err != nil {
|
||||
markAnswerAuditIncomplete(r, err)
|
||||
r.Note = "answer audit: " + err.Error()
|
||||
|
||||
+13
-10
@@ -41,19 +41,21 @@ func requestsBySourceLine(bySource map[string]sourceUsage) string {
|
||||
return "**Requests by source:** " + strings.Join(parts, " · ") + "\n\n"
|
||||
}
|
||||
|
||||
// armStats is one arm's aggregate over a -json report, using the same
|
||||
// accounting conventions as renderBody: spend totals cover accounted runs
|
||||
// (failures included) and per-solved figures divide by accounted solves.
|
||||
// armStats is one arm's aggregate over a -json report: spend totals and
|
||||
// per-solved figures cover accounted runs (failures included), while wall
|
||||
// rates (Solved/hour, wall/task) cover every run.
|
||||
type armStats struct {
|
||||
Ran, Pass1, Solved, AccountedSolved int
|
||||
Steps, Tools, Rounds, PlannerCalls int
|
||||
Tokens, Hit, Miss int
|
||||
Cost float64
|
||||
WallMs int64
|
||||
FirstHit, FirstMiss int64
|
||||
Damaged, WithCorrect int
|
||||
TTCS, TTFT []int64
|
||||
ByClass map[string]classStats
|
||||
// WallMs is all-run wall (a killed run's wall was still spent);
|
||||
// AccountedWallMs is the accounted subset the per-solved wall cost divides.
|
||||
WallMs, AccountedWallMs int64
|
||||
FirstHit, FirstMiss int64
|
||||
Damaged, WithCorrect int
|
||||
TTCS, TTFT []int64
|
||||
ByClass map[string]classStats
|
||||
// Fanout covers every run in the arm, no-solution tasks included: work the
|
||||
// agent delegated is work it paid for whether or not the task was solvable.
|
||||
Fanout fanoutMetrics
|
||||
@@ -107,6 +109,7 @@ func aggregateArm(results []result) armStats {
|
||||
}
|
||||
c.WallMs += r.WallMs
|
||||
s.ByClass[label] = c
|
||||
s.WallMs += r.WallMs
|
||||
if r.Unaccounted {
|
||||
continue
|
||||
}
|
||||
@@ -119,7 +122,7 @@ func aggregateArm(results []result) armStats {
|
||||
s.Hit += r.CacheHitTokens
|
||||
s.Miss += r.CacheMissTokens
|
||||
s.Cost += r.Cost
|
||||
s.WallMs += r.WallMs
|
||||
s.AccountedWallMs += r.WallMs
|
||||
s.PlannerCalls += r.UsageBySource["planner"].Calls
|
||||
if r.Trajectory != nil {
|
||||
s.Rounds += r.Trajectory.ModelRounds
|
||||
@@ -309,7 +312,7 @@ func compareReports(pathA, pathB string) (string, error) {
|
||||
fmt.Fprintf(&b, "| Model rounds / solved | %s | %s |\n", perSolved(float64(a.Rounds), a.AccountedSolved), perSolved(float64(bStats.Rounds), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Tool calls / solved | %s | %s |\n", perSolved(float64(a.Tools), a.AccountedSolved), perSolved(float64(bStats.Tools), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Tokens / solved | %s | %s |\n", perSolved(float64(a.Tokens), a.AccountedSolved), perSolved(float64(bStats.Tokens), bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.WallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.WallMs)/1000, bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.AccountedWallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.AccountedWallMs)/1000, bStats.AccountedSolved))
|
||||
fmt.Fprintf(&b, "| Cost / solved | %s | %s |\n", perSolved(a.Cost, a.AccountedSolved), perSolved(bStats.Cost, bStats.AccountedSolved))
|
||||
b.WriteString(marginalUtilitySection(a, bStats))
|
||||
b.WriteString(memoryUtilitySection(pathA, pathB))
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
// Wall population rule: a rate figure divides every run's wall (a killed run
|
||||
// still spent it); a per-solved cost divides the accounted wall of solved runs.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// reportSolvedPerHour reads the "Solved/hour" number the suite report prints.
|
||||
func reportSolvedPerHour(t *testing.T, report string) string {
|
||||
t.Helper()
|
||||
m := regexp.MustCompile(`\*\*Solved/hour\*\* ([\d.]+)`).FindStringSubmatch(report)
|
||||
if m == nil {
|
||||
t.Fatalf("suite report has no Solved/hour line:\n%s", report)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// compareSolvedPerHour reads the Solved/hour column out of the compare table's
|
||||
// one arm row.
|
||||
func compareSolvedPerHour(t *testing.T, report string) string {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(report, "\n") {
|
||||
if !strings.HasPrefix(line, "| `arm` |") {
|
||||
continue
|
||||
}
|
||||
cells := strings.Split(line, "|")
|
||||
if len(cells) < 8 {
|
||||
t.Fatalf("compare row has %d cells:\n%s", len(cells), line)
|
||||
}
|
||||
return strings.TrimSpace(cells[7])
|
||||
}
|
||||
t.Fatalf("compare table has no arm row:\n%s", report)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Solved/hour is a rate, not a per-solved figure: the suite report divides every
|
||||
// run's wall (a killed run's tokens are unknown, its wall was still spent), so
|
||||
// aggregateArm must not drop the unaccounted run's wall from that denominator.
|
||||
func TestCompareSolvedPerHourUsesTheSameWallAsTheReport(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
results := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
path := writeArm(t, dir, "arm.json", results)
|
||||
|
||||
want := reportSolvedPerHour(t, renderBody(results))
|
||||
|
||||
report, err := multiCompareReport([]string{path, path})
|
||||
if err != nil {
|
||||
t.Fatalf("multiCompareReport: %v", err)
|
||||
}
|
||||
if got := compareSolvedPerHour(t, report); got != want {
|
||||
t.Fatalf("compare Solved/hour = %s, suite report says %s for the same artifact\n"+
|
||||
"1600s of known wall (one 30-minute killed run) went missing from the compare denominator:\n%s",
|
||||
got, want, report)
|
||||
}
|
||||
}
|
||||
|
||||
// Same root cause, second printed number: the per-class table counts every
|
||||
// run's wall (c.WallMs is accumulated before the Unaccounted continue) while the
|
||||
// global marginal-utility line divided only accounted wall by the same task
|
||||
// denominator. One section, two populations: the class row says A cost +870.0s
|
||||
// per task more, the global line said -30.0s less.
|
||||
func TestMarginalUtilityGlobalWallMatchesThePerClassRows(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
mk := func(id string, wallMs int64, unaccounted bool) result {
|
||||
r := result{task: task{ID: id, Class: "bugfix"}, Passed: true, WallMs: wallMs}
|
||||
r.Unaccounted = unaccounted
|
||||
return r
|
||||
}
|
||||
a := []result{
|
||||
mk("ok", 60_000, false),
|
||||
mk("killed", 1_800_000, true),
|
||||
}
|
||||
b := []result{
|
||||
mk("ok", 60_000, false),
|
||||
mk("killed2", 60_000, false),
|
||||
}
|
||||
pathA := writeArm(t, dir, "a.json", a)
|
||||
pathB := writeArm(t, dir, "b.json", b)
|
||||
|
||||
report, err := compareReports(pathA, pathB)
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
var row string
|
||||
for line := range strings.SplitSeq(report, "\n") {
|
||||
if strings.HasPrefix(line, "| bugfix |") {
|
||||
row = line
|
||||
}
|
||||
}
|
||||
if row == "" {
|
||||
t.Fatalf("no bugfix class row:\n%s", report)
|
||||
}
|
||||
global := regexp.MustCompile(`wall/task ([+-][\d.]+)s`).FindStringSubmatch(report)
|
||||
if global == nil {
|
||||
t.Fatalf("no global wall/task line:\n%s", report)
|
||||
}
|
||||
// The one class row reports A 930.0s/task, B 60.0s/task, i.e. +870.0s.
|
||||
if global[1] != "+870.0" {
|
||||
t.Fatalf("global wall/task = %ss while the per-class row beside it says +870.0s "+
|
||||
"(A 930.0s vs B 60.0s): the global figure drops the unaccounted run's wall, "+
|
||||
"the class row keeps it\n%s\n%s", global[1], row, report)
|
||||
}
|
||||
}
|
||||
|
||||
// compareOutput pins a compare rendering byte for byte. RX_UPDATE_GOLDEN
|
||||
// rewrites it; the no-unaccounted fixture is the population where the two wall
|
||||
// totals coincide, so the readout must not move when only the accounting does.
|
||||
func compareOutput(t *testing.T, dir, name, got string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if os.Getenv("RX_UPDATE_GOLDEN") != "" {
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if got != string(want) {
|
||||
t.Fatalf("%s: compare output changed\n--- got ---\n%s\n--- want ---\n%s", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A fixture with no unaccounted runs is where every wall is accounted wall:
|
||||
// both rates must render exactly as they did before the population split.
|
||||
func TestCompareOutputUnchangedWithoutUnaccountedRuns(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
goldenDir, err := filepath.Abs("testdata")
|
||||
if err != nil {
|
||||
t.Fatalf("testdata dir: %v", err)
|
||||
}
|
||||
t.Chdir(dir)
|
||||
a := []result{
|
||||
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 60_000, Attempt: 1, TTCSMs: 60_000,
|
||||
runMetrics: runMetrics{Steps: 7, ToolCalls: 10, PromptTokens: 900, CompletionTokens: 100, Cost: 0.02, Currency: "$"}},
|
||||
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
|
||||
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
|
||||
}
|
||||
b := []result{
|
||||
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 45_000, Attempt: 1, TTCSMs: 45_000,
|
||||
runMetrics: runMetrics{Steps: 5, ToolCalls: 10, PromptTokens: 700, CompletionTokens: 100, Cost: 0.01, Currency: "$"}},
|
||||
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
|
||||
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
|
||||
}
|
||||
writeArm(t, ".", "a.json", a)
|
||||
writeArm(t, ".", "b.json", b)
|
||||
|
||||
ab, err := compareReports("a.json", "b.json")
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
compareOutput(t, goldenDir, "no_unaccounted_ab.golden", ab)
|
||||
|
||||
multi, err := multiCompareReport([]string{"a.json", "b.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("multiCompareReport: %v", err)
|
||||
}
|
||||
compareOutput(t, goldenDir, "no_unaccounted_multi.golden", multi)
|
||||
}
|
||||
|
||||
// Wall seconds / solved divides the accounted wall by accounted solves: the
|
||||
// unaccounted run's wall must stay out of that ratio even though it now enters
|
||||
// every rate figure.
|
||||
func TestWallSecondsPerSolvedKeepsAccountedPopulation(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
a := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
b := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
|
||||
}
|
||||
report, err := compareReports(writeArm(t, dir, "a.json", a), writeArm(t, dir, "b.json", b))
|
||||
if err != nil {
|
||||
t.Fatalf("compareReports: %v", err)
|
||||
}
|
||||
// A: 60s accounted wall over 1 accounted solve; its all-run wall (1860s)
|
||||
// would print 1860.0 here, or 930.0 over all solves. B has no unaccounted
|
||||
// run, so both walls coincide at 60.0.
|
||||
if !strings.Contains(report, "| Wall seconds / solved | 60.0 | 60.0 |") {
|
||||
t.Fatalf("Wall seconds / solved is not accounted wall / accounted solves:\n%s", report)
|
||||
}
|
||||
for _, wrong := range []string{"| Wall seconds / solved | 1860.0 |", "| Wall seconds / solved | 930.0 |"} {
|
||||
if strings.Contains(report, wrong) {
|
||||
t.Fatalf("Wall seconds / solved divided the all-run wall (%s):\n%s", wrong, report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The suite report's Solved/hour convention is the reference the compare mode
|
||||
// now follows: every attempt's wall, solved runs over that total.
|
||||
func TestSuiteReportSolvedPerHourCountsEveryRun(t *testing.T) {
|
||||
withKilled := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
|
||||
}
|
||||
if got := reportSolvedPerHour(t, renderBody(withKilled)); got != "3.9" {
|
||||
t.Errorf("suite report Solved/hour = %s, want 3.9 (2 solves over 1860s)", got)
|
||||
}
|
||||
allAccounted := []result{
|
||||
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
|
||||
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
|
||||
}
|
||||
if got := reportSolvedPerHour(t, renderBody(allAccounted)); got != "60.0" {
|
||||
t.Errorf("suite report Solved/hour = %s, want 60.0 (2 solves over 120s)", got)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func main() {
|
||||
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off ("+ablation.ModuleList()+"; none|all)")
|
||||
foldIndexFlag := flag.String("fold-index", "", "fold-index arm: how much of the model-visible address list to keep (default|half|quarter|off)")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory (<task-id>.trial-N.trajectory.jsonl per trial under -trials)")
|
||||
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
|
||||
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
@@ -585,10 +585,11 @@ func runTask(cfg suiteConfig, t task) result {
|
||||
}
|
||||
|
||||
mtr.record(&r)
|
||||
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
|
||||
stem := trajectoryStem(t.ID, cfg.trial, cfg.trials)
|
||||
if trajPath = lastSegmentTrajectory(runTrajDir, stem, r.Segments); trajPath != "" {
|
||||
recordTaskTrajectory(&r, trajPath, t)
|
||||
}
|
||||
if !auditAnswerRun(&r, runTrajDir, t) {
|
||||
if !auditAnswerRun(&r, runTrajDir, stem, t) {
|
||||
return r
|
||||
}
|
||||
// A killed child never writes metrics, so the deadline is the only place
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import "reasonix/internal/contract/tool"
|
||||
|
||||
// callNesting tells the model's own calls from calls made on its behalf: a
|
||||
// subagent's (stamped with ParentID) and those a running tool makes through
|
||||
// the agent's invoker (run_script's), which carry an id derived from their
|
||||
// caller's and arrive inside its dispatch→result window. Either kind ran
|
||||
// inside one top-level call, so it bought no model request of its own.
|
||||
type callNesting struct {
|
||||
open map[string]bool // top-level calls dispatched and not yet resulted
|
||||
}
|
||||
|
||||
// child reports whether a tool event belongs to a call made on the model's
|
||||
// behalf, and tracks which top-level calls are still running.
|
||||
func (n *callNesting) child(kind, id, parentID string) bool {
|
||||
if parentID != "" {
|
||||
return true
|
||||
}
|
||||
for caller := range n.open {
|
||||
if tool.IsNestedCallOf(id, caller) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "tool_dispatch":
|
||||
if n.open == nil {
|
||||
n.open = map[string]bool{}
|
||||
}
|
||||
n.open[id] = true
|
||||
case "tool_result":
|
||||
delete(n.open, id)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type trajScan struct {
|
||||
gapReason, gapCompl, gapPrompt int64
|
||||
|
||||
delegationToolMs map[string]int64
|
||||
nesting callNesting
|
||||
}
|
||||
|
||||
// modelAttempt is one sampling attempt's wall interval; planner marks attempts
|
||||
|
||||
@@ -25,6 +25,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
|
||||
}
|
||||
defer f.Close()
|
||||
inModel := true
|
||||
var nesting callNesting
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
|
||||
for sc.Scan() {
|
||||
@@ -32,7 +33,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
|
||||
continue
|
||||
}
|
||||
after := rec.TS > cutoffUnixMs
|
||||
@@ -80,6 +81,7 @@ func roundEnds(path string) []int64 {
|
||||
var ends []int64
|
||||
var lastResult int64
|
||||
inModel := true
|
||||
var nesting callNesting
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
|
||||
for sc.Scan() {
|
||||
@@ -87,7 +89,7 @@ func roundEnds(path string) []int64 {
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
|
||||
continue
|
||||
}
|
||||
switch rec.Event.Kind {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -36,7 +38,7 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
|
||||
var runErr error
|
||||
for _, seg := range segs {
|
||||
metricsPath := filepath.Join(metricsDir, fmt.Sprintf(".run-metrics-%d.json", seg.index))
|
||||
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
|
||||
trajPath := segmentTrajectoryPath(trajDir, trajectoryStem(t.ID, cfg.trial, cfg.trials), seg, len(segs))
|
||||
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, cfg.bin, args...)
|
||||
@@ -85,6 +87,35 @@ func legMetrics(path string) (m runMetrics, partial bool, err error) {
|
||||
return m, false, err
|
||||
}
|
||||
|
||||
// trajectoryStem names one run's trajectory files: the task id, and the trial
|
||||
// when -trials repeats the task, so a later trial cannot overwrite an earlier
|
||||
// one. A single run keeps the bare id every reader already knows.
|
||||
func trajectoryStem(id string, trial, trials int) string {
|
||||
if trials < 2 {
|
||||
return id
|
||||
}
|
||||
return fmt.Sprintf("%s.trial-%d", id, trial)
|
||||
}
|
||||
|
||||
// trajectoryTaskID is the task a trajectory file recorded: its name without
|
||||
// the trial mark of trajectoryStem or the leg mark of segmentTrajectoryPath.
|
||||
func trajectoryTaskID(path string) string {
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".trajectory.jsonl")
|
||||
id = trimNumberedMark(id, ".seg")
|
||||
return trimNumberedMark(id, ".trial-")
|
||||
}
|
||||
|
||||
func trimNumberedMark(id, mark string) string {
|
||||
i := strings.LastIndex(id, mark)
|
||||
if i < 0 {
|
||||
return id
|
||||
}
|
||||
if _, err := strconv.Atoi(id[i+len(mark):]); err != nil {
|
||||
return id
|
||||
}
|
||||
return id[:i]
|
||||
}
|
||||
|
||||
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
|
||||
// the record of the one before it.
|
||||
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
|
||||
|
||||
@@ -153,8 +153,7 @@ func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error]
|
||||
}
|
||||
|
||||
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")]
|
||||
id := trajectoryTaskID(path)
|
||||
records, digest, err := readSFTSnapshot(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
## e2ebench A/B: `a.json` vs `b.json`
|
||||
|
||||
| Metric | A | B |
|
||||
|---|---:|---:|
|
||||
| Solved | 1/2 (50%) | 1/2 (50%) |
|
||||
| Pass@1 | 50% | 50% |
|
||||
| TTFT median | 0ms | 0ms |
|
||||
| TTCS median | 1m00s | 45.0s |
|
||||
| TTCS p90 | 1m00s | 45.0s |
|
||||
| Cache hit | n/a | n/a |
|
||||
| First-request cache hit | n/a | n/a |
|
||||
| Overthinking damage | n/a | n/a |
|
||||
| Model requests / solved | 16.0 | 14.0 |
|
||||
| Planner requests / solved | 0.0 | 0.0 |
|
||||
| Model rounds / solved | 0.0 | 0.0 |
|
||||
| Tool calls / solved | 24.0 | 24.0 |
|
||||
| Tokens / solved | 2400.0 | 2200.0 |
|
||||
| Wall seconds / solved | 150.0 | 135.0 |
|
||||
| Cost / solved | 0.1 | 0.0 |
|
||||
|
||||
**Marginal utility (A − B):** accuracy +0.0pp · wall/task +7.5s
|
||||
|
||||
| Class | A solved | B solved | Δ accuracy | A wall/task | B wall/task | Δ wall |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| bugfix | 1/1 | 1/1 | +0.0pp | 60.0s | 45.0s | +15.0s |
|
||||
| perf | 0/1 | 0/1 | +0.0pp | 90.0s | 90.0s | +0.0s |
|
||||
|
||||
|
||||
### Pareto: accuracy vs TTCS
|
||||
|
||||
```
|
||||
Accuracy
|
||||
100% |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
| B ✗
|
||||
|
|
||||
40% |
|
||||
+----------------------------------------------→ TTCS
|
||||
43.5s 1m01s
|
||||
A=a B=b
|
||||
```
|
||||
|
||||
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
|
||||
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
|
||||
|
||||
|
||||
### Reproduction
|
||||
|
||||
- capability set: unknown (a report records no capability set)
|
||||
- task prompts: same 2 task ids and prompts
|
||||
- not compared: commit, build, configuration, grader, task files other than the prompt
|
||||
|
||||
| Rate | A | B | B − A (95% CI) | |
|
||||
|---|---:|---:|---:|---|
|
||||
| grader pass (solvable) | 1/2 | 1/2 | +0.0pp (-57.3, +57.3) | within noise |
|
||||
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves.</sub>
|
||||
@@ -0,0 +1,36 @@
|
||||
## e2ebench comparison: 2 arms
|
||||
|
||||
| Arm | Pass@1 | Solved | TTFT | TTCS median | TTCS p90 | Solved/hour | 1st-req cache | Requests/solved | Tokens/solved | Cost/solved |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `a` | 50% | 1/2 | 0ms | 1m00s | 1m00s | 24.0 | n/a | 16.0 | 2,400 | 0.0500 |
|
||||
| `b` | 50% | 1/2 | 0ms | 45.0s | 45.0s | 26.7 | n/a | 14.0 | 2,200 | 0.0400 |
|
||||
|
||||
### Pareto: accuracy vs TTCS
|
||||
|
||||
```
|
||||
Accuracy
|
||||
100% |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
| B ✗
|
||||
|
|
||||
40% |
|
||||
+----------------------------------------------→ TTCS
|
||||
43.5s 1m01s
|
||||
A=a B=b
|
||||
```
|
||||
|
||||
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
|
||||
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
|
||||
|
||||
### Per-class winners
|
||||
|
||||
| Class | `a` | `b` | Winner |
|
||||
|---|---:|---:|---|
|
||||
| bugfix | 100% · 1m00s | 100% · 45.0s | b |
|
||||
| perf | 0% · — | 0% · — | a |
|
||||
|
||||
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves; TTCS charges a retried solve with its failed attempts' wall.</sub>
|
||||
@@ -20,9 +20,9 @@ type trajectorySummary struct {
|
||||
SpanMs int64 `json:"span_ms"`
|
||||
ToolMs int64 `json:"tool_ms"`
|
||||
ModelMs int64 `json:"model_ms"` // SpanMs − tool wall clock, floored at zero
|
||||
// Round decomposition: a round's gap runs from the turn start (or the
|
||||
// batch's last tool_result) to the next top-level tool_dispatch; the
|
||||
// final segment to the last record is the answer round.
|
||||
// ModelRounds counts executor round trips: one per batch of top-level calls
|
||||
// plus the answer segment. Calls a subagent or a running tool (run_script)
|
||||
// makes are not top-level and cut no round; retries in a gap add none.
|
||||
ModelRounds int `json:"model_rounds"`
|
||||
ModelGapTotalMs int64 `json:"model_gap_total_ms"`
|
||||
ModelGapP95Ms int64 `json:"model_gap_p95_ms"`
|
||||
@@ -105,6 +105,9 @@ type trajectorySummary struct {
|
||||
PromptTokensSeen int64 `json:"prompt_tokens_seen,omitempty"` // Σ prompt tokens (schema share denominator)
|
||||
PrefixResets int `json:"prefix_resets,omitempty"` // usage events with prefixChanged
|
||||
ConnectCalls int `json:"connect_calls,omitempty"` // connect_tool_source dispatches
|
||||
// NestedCalls counts results of calls a running tool made through the
|
||||
// agent's invoker; they are outside TopLevelCalls and every round figure.
|
||||
NestedCalls int `json:"nested_calls,omitempty"`
|
||||
|
||||
// Cold/warm evidence: the first top-level request's cache split. A cold
|
||||
// session pays the whole prefix as miss; a warmed one starts near-hit.
|
||||
@@ -421,9 +424,21 @@ func (t *trajScan) record(rec trajectoryRecord) {
|
||||
}
|
||||
t.compFrom = 0
|
||||
}
|
||||
if rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
|
||||
// Subagent calls overlap the parent's wall clock; counting them
|
||||
// would double-book the span and split the parent's rounds.
|
||||
t.recordTool(rec)
|
||||
}
|
||||
|
||||
// recordTool books a top-level tool event to its round and batch.
|
||||
func (t *trajScan) recordTool(rec trajectoryRecord) {
|
||||
tl := rec.Event.Tool
|
||||
if tl == nil {
|
||||
return
|
||||
}
|
||||
if t.nesting.child(rec.Event.Kind, tl.ID, tl.ParentID) {
|
||||
// Nested calls overlap their caller's wall clock; counting them would
|
||||
// double-book the span and split the caller's round.
|
||||
if tl.ParentID == "" && rec.Event.Kind == "tool_result" {
|
||||
t.s.NestedCalls++
|
||||
}
|
||||
return
|
||||
}
|
||||
switch rec.Event.Kind {
|
||||
@@ -434,9 +449,10 @@ func (t *trajScan) record(rec trajectoryRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
// closeGap ends one model round; recovery-tainted rounds are booked apart so
|
||||
// clean latency stays comparable across providers with different flake rates.
|
||||
// The gap is queued until its batch closes and can classify the round.
|
||||
// closeGap ends one model round: the gap from the turn start or the last
|
||||
// top-level tool_result to the next top-level tool_dispatch. Recovery-tainted
|
||||
// rounds are booked apart so clean latency stays comparable across providers
|
||||
// with different flake rates. The gap is queued until its batch classifies it.
|
||||
func (t *trajScan) closeGap(gap int64) {
|
||||
t.gaps = append(t.gaps, gap)
|
||||
t.pendingGaps = append(t.pendingGaps, gapInfo{
|
||||
|
||||
@@ -625,3 +625,62 @@ func TestSummarizeTrajectoryCountsRefusedRoundsAsWaste(t *testing.T) {
|
||||
t.Error("a refused round must carry its gap time into the waste breakdown")
|
||||
}
|
||||
}
|
||||
|
||||
// A run_script call's own calls run under ids derived from the script's, inside
|
||||
// its dispatch→result window. They are one model request, not one per call, so
|
||||
// they must neither cut model rounds nor reach round outcomes as top-level work.
|
||||
func TestSummarizeTrajectoryFoldsScriptCallsIntoTheirRound(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "script.trajectory.jsonl")
|
||||
lines := []string{
|
||||
`{"ts":1000,"event":{"kind":"turn_started"}}`,
|
||||
`{"ts":1900,"event":{"kind":"usage","usage":{"promptTokens":10}}}`,
|
||||
`{"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"call_s","name":"run_script"}}}`,
|
||||
`{"ts":2100,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.1","name":"read_file","readOnly":true}}}`,
|
||||
`{"ts":2200,"event":{"kind":"tool_result","tool":{"id":"call_s.1","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2100,"endedAt":2200}}}`,
|
||||
`{"ts":2300,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.2","name":"read_file","readOnly":true}}}`,
|
||||
`{"ts":2400,"event":{"kind":"tool_result","tool":{"id":"call_s.2","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2300,"endedAt":2400}}}`,
|
||||
`{"ts":2500,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.3","name":"edit_file"}}}`,
|
||||
`{"ts":2600,"event":{"kind":"tool_result","tool":{"id":"call_s.3","name":"edit_file","durationMs":100,"startedAt":2500,"endedAt":2600}}}`,
|
||||
`{"ts":2700,"event":{"kind":"tool_result","tool":{"id":"call_s","name":"run_script","durationMs":700,"startedAt":2000,"endedAt":2700}}}`,
|
||||
`{"ts":3900,"event":{"kind":"usage","usage":{"promptTokens":20}}}`,
|
||||
`{"ts":4000,"event":{"kind":"turn_done"}}`,
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
s, err := summarizeTrajectory(path)
|
||||
if err != nil {
|
||||
t.Fatalf("summarizeTrajectory: %v", err)
|
||||
}
|
||||
if s.ExecutorRequests != 2 {
|
||||
t.Fatalf("executor requests = %d, want 2", s.ExecutorRequests)
|
||||
}
|
||||
if s.ModelRounds != 2 {
|
||||
t.Errorf("model rounds = %d, want 2 (script round + answer), one per model request", s.ModelRounds)
|
||||
}
|
||||
if s.ModelGapTotalMs != 2300 {
|
||||
t.Errorf("model gap total = %d, want 2300 (1000 before the script, 1300 after it)", s.ModelGapTotalMs)
|
||||
}
|
||||
if s.TopLevelCalls != 1 || s.ToolBatches != 1 {
|
||||
t.Errorf("top-level calls/batches = %d/%d, want 1/1", s.TopLevelCalls, s.ToolBatches)
|
||||
}
|
||||
if s.NestedCalls != 3 {
|
||||
t.Errorf("nested calls = %d, want 3", s.NestedCalls)
|
||||
}
|
||||
if s.ToolMs != 700 {
|
||||
t.Errorf("tool ms = %d, want 700 (nested time is inside the script's)", s.ToolMs)
|
||||
}
|
||||
total := 0
|
||||
for _, n := range s.RoundOutcomes {
|
||||
total += n
|
||||
}
|
||||
if total != 2 || s.RoundOutcomes["finalization"] != 1 {
|
||||
t.Errorf("round outcomes = %v, want two rounds, one of them finalization", s.RoundOutcomes)
|
||||
}
|
||||
if ends := roundEnds(path); len(ends) != 1 || ends[0] != 2700 {
|
||||
t.Errorf("round ends = %v, want [2700]", ends)
|
||||
}
|
||||
if split := splitAtCorrect(path, 0); split.RoundsAfter != 1 || split.CallsAfter != 1 {
|
||||
t.Errorf("split = %+v, want one round and one call after the cutoff", split)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func trialResult(id string, trial int, passed bool) result {
|
||||
@@ -68,3 +73,83 @@ func TestTrialsLineIsSilentWithoutTrials(t *testing.T) {
|
||||
t.Fatalf("line = %q, want nothing for a run without trials", got)
|
||||
}
|
||||
}
|
||||
|
||||
// trajectoryAgent stands in for the reasonix binary and writes a one-record
|
||||
// trajectory wherever --trajectory points, so a test can see which files a run
|
||||
// leaves behind.
|
||||
func trajectoryAgent(t *testing.T) string {
|
||||
t.Helper()
|
||||
requireShellStub(t)
|
||||
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
|
||||
script := `#!/usr/bin/env bash
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--trajectory) printf '{"ts":1,"event":{"kind":"turn_done"}}\n' > "$2"; shift 2;;
|
||||
--metrics) printf '{"steps":1,"complete":true}' > "$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// Each trial records its own trajectory: a shared name would leave only the
|
||||
// last trial's file, and every digest of the earlier ones would read it.
|
||||
func TestTrialsKeepOneTrajectoryPerTrial(t *testing.T) {
|
||||
dir, taskDir := testenv.TempDir(t), testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "prompt.md"), []byte("p"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := suiteConfig{bin: trajectoryAgent(t), trials: 2, trajDir: dir}
|
||||
total := 0
|
||||
results := runTrials(cfg, task{ID: "demo", Prompt: "p", MaxSteps: 4, TimeoutSec: 60, dir: taskDir}, &total)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
got, err := filepath.Glob(filepath.Join(dir, "*.trajectory.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range got {
|
||||
got[i] = filepath.Base(got[i])
|
||||
}
|
||||
slices.Sort(got)
|
||||
want := []string{"demo.trial-1.trajectory.jsonl", "demo.trial-2.trajectory.jsonl"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("trajectory files = %v, want %v", got, want)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Trajectory == nil || filepath.Base(r.Trajectory.Path) != want[r.Trial-1] {
|
||||
t.Errorf("trial %d digest = %+v, want it read from %s", r.Trial, r.Trajectory, want[r.Trial-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single trial keeps the name every reader already knows.
|
||||
func TestSingleTrialKeepsTheTaskTrajectoryName(t *testing.T) {
|
||||
if got := trajectoryStem("demo", 1, 1); got != "demo" {
|
||||
t.Fatalf("stem = %q, want demo", got)
|
||||
}
|
||||
if got := trajectoryStem("demo", 0, 0); got != "demo" {
|
||||
t.Fatalf("stem = %q, want demo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Readers that need the task behind a file strip the trial and segment marks.
|
||||
func TestTrajectoryTaskIDReadsEveryFileShape(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"demo.trajectory.jsonl": "demo",
|
||||
"demo.trial-2.trajectory.jsonl": "demo",
|
||||
"demo.seg3.trajectory.jsonl": "demo",
|
||||
"demo.trial-2.seg3.trajectory.jsonl": "demo",
|
||||
"a.b.trial-10.trajectory.jsonl": "a.b",
|
||||
} {
|
||||
if got := trajectoryTaskID(filepath.Join("d", name)); got != want {
|
||||
t.Errorf("trajectoryTaskID(%s) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The host runs a scheduled child through the small schedulerun package. The
|
||||
// command line is not part of what it ships, and importing it again for one
|
||||
// entry point would bring back every command it carries.
|
||||
func TestTheHostDoesNotCarryTheCommandLine(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Skipf("go list is not available: %v", err)
|
||||
}
|
||||
for pkg := range strings.FieldsSeq(string(out)) {
|
||||
if pkg == "reasonix/internal/frontend/cli" {
|
||||
t.Fatal("the studio host depends on the command line package")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reasonix/internal/ext/modhost"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/surface"
|
||||
"reasonix/internal/frontend/remotehost"
|
||||
"reasonix/internal/frontend/schedulerun"
|
||||
"reasonix/internal/frontend/serve"
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
@@ -48,6 +50,7 @@ import (
|
||||
// Kinds register from init, so a binary builds only what it links. Without
|
||||
// these every Anthropic model answers "unknown kind" at switch time.
|
||||
_ "reasonix/internal/model/anthropic"
|
||||
_ "reasonix/internal/model/gemini"
|
||||
_ "reasonix/internal/model/openai"
|
||||
_ "reasonix/internal/model/responses"
|
||||
)
|
||||
@@ -121,6 +124,10 @@ func main() {
|
||||
if handled, code := update.MaybeRunTreeHandoff(os.Args[1:]); handled {
|
||||
os.Exit(code)
|
||||
}
|
||||
// A scheduled run is this same binary started by the in-kernel ticker.
|
||||
if scheduleChildArgs(os.Args[1:]) {
|
||||
os.Exit(schedulerun.Exec(os.Args[3:]))
|
||||
}
|
||||
page := flag.String("page", "", "directory holding the built Studio page")
|
||||
identity := flag.Bool("instance-id", false, "print the Studio instance this data home belongs to, and exit")
|
||||
// The shell around this process knows which build it is; this process does
|
||||
@@ -140,6 +147,8 @@ func main() {
|
||||
if *stripGrants {
|
||||
os.Exit(stripPackageGrants(os.Stdout, os.Stderr, *studioApp))
|
||||
}
|
||||
// Studio's own binary runs a Claude Code mod's module as Node.
|
||||
modhost.SetEmbedder(*studioApp, os.Environ())
|
||||
// Which launches are the same Studio is Reasonix's question, not a shell's:
|
||||
// the answer is the canonicalized data home, and a shell asks for it rather
|
||||
// than working it out from an environment it does not resolve.
|
||||
@@ -250,6 +259,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
// After the socket has drained, never before: a pane torn down under a live
|
||||
// request answers it with a half-closed kernel.
|
||||
defer hub.Shutdown()
|
||||
startScheduleTicker(ctx, lease != nil, hub, logs)
|
||||
|
||||
bound, err := bind(hub.Handler())
|
||||
phases.mark(startupBind)
|
||||
@@ -376,7 +386,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// This window is the only client of its kernel, so a system notification
|
||||
// reaches the person who asked for it. Every pane gets the same wrapper,
|
||||
// not just the one the launch started with.
|
||||
notifications, notifySet := hostNotifications(cfg)
|
||||
notifications, notifySet, phoneLink := hostNotifications(cfg)
|
||||
// One fold behind the status icon: every pane's events on the way through,
|
||||
// so the tray surface answers from what the panes did rather than from a
|
||||
// count the shell kept for itself.
|
||||
@@ -464,7 +474,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
return nil, err
|
||||
}
|
||||
hub.StartRecoveryGC(ctx)
|
||||
startCloudRemote(ctx, cfg, logs, hub)
|
||||
startCloudRemote(ctx, cfg, logs, hub, phoneLink)
|
||||
phases.mark(startupBackground)
|
||||
return hub, nil
|
||||
}
|
||||
@@ -490,7 +500,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
|
||||
}
|
||||
}
|
||||
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
|
||||
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService, phones *notify.PhoneLink) {
|
||||
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(logs, "remote cloud: %v\n", err)
|
||||
@@ -519,6 +529,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
}
|
||||
})
|
||||
}
|
||||
// Phone notifications leave through this connection, under this Studio's own
|
||||
// device credential.
|
||||
phones.Set(host)
|
||||
if registrar, ok := tasks.(interface{ SetCloudPhones(serve.CloudPhones) }); ok {
|
||||
registrar.SetCloudPhones(host)
|
||||
}
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
@@ -563,13 +579,17 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
|
||||
// says today, because deciding here would make "notify me" mean "notify me
|
||||
// after a restart". The shared [notifications] config is the one setting this
|
||||
// window and the CLI both answer to.
|
||||
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
|
||||
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings, *notify.PhoneLink) {
|
||||
set := notify.NewSettings(config.NotificationsConfig{})
|
||||
if cfg != nil {
|
||||
set.Store(cfg.Notifications)
|
||||
}
|
||||
sender := notify.NewPlatformSender()
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
|
||||
// One reporter for every runtime, so a burst across panes folds into one
|
||||
// report; its sender is the account connection, attached once that is up.
|
||||
link := ¬ify.PhoneLink{}
|
||||
phone := notify.NewPhone(link)
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set).WithPhone(phone) }, set, link
|
||||
}
|
||||
|
||||
// studioTray answers for a shell that can put an icon back whenever the setting
|
||||
|
||||
@@ -480,7 +480,7 @@ func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
|
||||
{Notifications: config.NotificationsConfig{Enabled: true}},
|
||||
nil,
|
||||
} {
|
||||
wrap, set := hostNotifications(cfg)
|
||||
wrap, set, _ := hostNotifications(cfg)
|
||||
if got := wrap(plain); got == event.Sink(plain) {
|
||||
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
|
||||
}
|
||||
@@ -551,3 +551,45 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
|
||||
}
|
||||
}
|
||||
|
||||
type phoneReports chan [2]any
|
||||
|
||||
func (p phoneReports) NotifyPhones(_ context.Context, kind string, count int) error {
|
||||
p <- [2]any{kind, count}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A runtime's own events, through the wrapper every pane gets, reach the account
|
||||
// connection as a kind and a count and nothing else, and only once the switches
|
||||
// ask for it.
|
||||
func TestAnApprovalOnAPaneReachesThePhoneLinkAsATypeAndACount(t *testing.T) {
|
||||
cfg := &config.Config{Notifications: config.NotificationsConfig{Phone: config.PhoneNotificationsConfig{Enabled: true, Approval: true}}}
|
||||
wrap, set, link := hostNotifications(cfg)
|
||||
reports := make(phoneReports, 4)
|
||||
link.Set(reports)
|
||||
sink := wrap(serve.NewBroadcaster())
|
||||
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "1", Tool: "bash", Subject: "git push --force"}})
|
||||
select {
|
||||
case got := <-reports:
|
||||
if got != [2]any{"approval", 1} {
|
||||
t.Fatalf("reported %v, want a type and a count and no text", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("an approval on a pane did not reach the phone link")
|
||||
}
|
||||
|
||||
sink.Emit(event.Event{Kind: event.AskRequest})
|
||||
select {
|
||||
case got := <-reports:
|
||||
t.Fatalf("reported %v for a kind whose switch is off", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
set.Store(config.NotificationsConfig{})
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest})
|
||||
select {
|
||||
case got := <-reports:
|
||||
t.Fatalf("reported %v after the master switch was turned off", got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/frontend/serve"
|
||||
"reasonix/internal/runtime/schedrun"
|
||||
"reasonix/internal/runtime/scheduler"
|
||||
)
|
||||
|
||||
// scheduleChildArgs says argv is a supervised scheduled run: the supervisor
|
||||
// starts this binary as `schedule exec <id>`, and this host has no CLI of its own.
|
||||
func scheduleChildArgs(args []string) bool {
|
||||
return len(args) >= 2 && args[0] == "schedule" && args[1] == "exec"
|
||||
}
|
||||
|
||||
// scheduleEligible is when this kernel may start scheduled runs: it has a
|
||||
// supervised lifetime (a parent that ends it) and something the person can see.
|
||||
// An orphan, or a kernel nobody can see, never spends on anyone's behalf.
|
||||
func scheduleEligible(supervised, iconLive bool, panes int) bool {
|
||||
return supervised && (iconLive || panes > 0)
|
||||
}
|
||||
|
||||
// startScheduleTicker runs the in-kernel driver of scheduled runs. Every kernel
|
||||
// on the machine may run it; the ticker lock lets one tick at a time.
|
||||
func startScheduleTicker(ctx context.Context, supervised bool, hub *serve.Hub, logs io.Writer) {
|
||||
if !supervised {
|
||||
return
|
||||
}
|
||||
loop := &scheduler.Loop{
|
||||
Eligible: func() bool { return scheduleEligible(supervised, (&studioTray{}).IconLive(), len(hub.Runtimes())) },
|
||||
Lock: func() (func(), error) {
|
||||
store, _, err := scheduler.OpenUserStore(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.TryAcquire(filepath.Join(store.Dir(), "scheduler.lock"))
|
||||
},
|
||||
Tick: func(ctx context.Context) (scheduler.Result, error) {
|
||||
store, policy, err := scheduler.OpenUserStore(nil)
|
||||
if err != nil {
|
||||
return scheduler.Result{}, err
|
||||
}
|
||||
return (&scheduler.Scheduler{Store: store, Policy: policy, Runner: &schedrun.Supervisor{Store: store, Policy: policy}}).Tick(ctx)
|
||||
},
|
||||
Done: func(res scheduler.Result, err error) {
|
||||
for _, r := range res.Reports {
|
||||
fmt.Fprintf(logs, "schedule: ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
fmt.Fprintf(logs, "schedule: skipped %s: %s\n", s.ScheduleID, s.Reason)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "schedule:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
go loop.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAScheduleChildIsRecognisedByItsArguments(t *testing.T) {
|
||||
for args, want := range map[string]bool{"schedule exec sch_x/1": true, "schedule exec": true, "schedule list": false, "exec schedule": false, "": false} {
|
||||
if got := scheduleChildArgs(strings.Fields(args)); got != want {
|
||||
t.Errorf("%q = %v, want %v", args, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An orphan, or a kernel nobody can see, never starts a scheduled run.
|
||||
func TestSchedulesStartOnlyForASupervisedKernelThatCanBeSeen(t *testing.T) {
|
||||
cases := []struct {
|
||||
supervised, icon bool
|
||||
panes int
|
||||
want bool
|
||||
}{
|
||||
{true, true, 0, true}, {true, false, 1, true}, {true, true, 3, true},
|
||||
{true, false, 0, false}, {false, true, 2, false}, {false, false, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := scheduleEligible(c.supervised, c.icon, c.panes); got != c.want {
|
||||
t.Errorf("%+v = %v", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/model/anthropic"
|
||||
_ "reasonix/internal/model/gemini"
|
||||
_ "reasonix/internal/model/openai"
|
||||
_ "reasonix/internal/model/responses"
|
||||
_ "reasonix/internal/tools/builtin"
|
||||
|
||||
+267
-1
@@ -2,11 +2,102 @@
|
||||
status: active
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
reviewed: 2026-10-13
|
||||
reviewed: 2026-10-18
|
||||
---
|
||||
|
||||
# Foundation palette verification
|
||||
|
||||
## Current preset editor verification / 当前预设编辑页验收
|
||||
|
||||
final result: blocked
|
||||
|
||||
This section supersedes earlier preset-card visual acceptance below. The selected
|
||||
source is the user's attached `exec-e196c6a9-d8d9-437d-a6ee-0e99a3651845.png`,
|
||||
1487 × 1058 image pixels; CSS viewport and density are unspecified.
|
||||
|
||||
The image specifies a catalog followed by a separate content editor, not a drawer. The existing
|
||||
Settings shell remains the application-owned frame.
|
||||
|
||||
The latest editor instruction supersedes the original advanced disclosure:
|
||||
the user's `codex-clipboard-c3951359-4571-4572-ad30-9f4517a2f7db.png` reference
|
||||
places both textareas and the runtime-context switch directly in the editor.
|
||||
Scope and ID are also exposed. Settings navigation now reads Persona / 人设.
|
||||
|
||||
本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立
|
||||
编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置
|
||||
收起、个人范围、取消与保存操作固定在底部。
|
||||
|
||||
- Implementation screenshot: unavailable. Browser acquisition of the local
|
||||
preview was rejected by the browser URL security policy. No alternative browser
|
||||
surface or raw browser command was used to bypass the refusal.
|
||||
- Viewport, implementation pixel dimensions, CSS size and density normalization:
|
||||
unmeasured. Full-view and focused visual comparisons remain blocked.
|
||||
- Typography: existing Studio family and weight tokens retained; rendering,
|
||||
wrapping and optical weight require browser comparison.
|
||||
- Spacing/layout: separate editor, expandable rows and flex footer implemented;
|
||||
actual proportions, scrolling and narrow-screen overflow remain unverified.
|
||||
- Colors: existing surface, text, focus and border tokens reused; rendered theme
|
||||
contrast remains unverified.
|
||||
- Assets: existing product icons retained; no generated product assets or new
|
||||
handcrafted icon geometry. Source brand is supplied by the existing shell.
|
||||
- Copy: name, purpose, persona, capabilities, scope and ID follow the selected
|
||||
references, with Chinese/English labels. Existing backend limits remain
|
||||
authoritative rather than adopting illustrative mockup character limits.
|
||||
- Interaction regressions cover creation, copy, literal persona fields, invalid
|
||||
parameters, failed saves, navigation/close confirmation, pending saves and focus.
|
||||
These are jsdom checks and do not substitute for browser visual evidence.
|
||||
|
||||
The catalog-button follow-up uses the user's 1374 × 916 browser annotation as
|
||||
the before evidence. The create button now uses one horizontal flex row, a
|
||||
36px minimum height and 16px horizontal padding.
|
||||
|
||||
The heading uses a two-column
|
||||
grid, with its description on a separate full-width row. After-render visual
|
||||
verification remains blocked by the browser policy described above.
|
||||
|
||||
Remaining checklist:
|
||||
|
||||
1. Acquire the authorized preview and capture the specified creation state and catalog.
|
||||
2. Compare the full view and editor regions; verify light/dark, narrow scrolling and console output.
|
||||
3. Resolve P0–P2 findings before marking visual acceptance passed.
|
||||
|
||||
Earlier passing sections below are historical evidence for their own changes.
|
||||
|
||||
待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏
|
||||
滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。
|
||||
|
||||
## Built-in cards and new-task default / 内置卡片与新任务默认
|
||||
|
||||
final result: blocked
|
||||
|
||||
Source: the user's selected Harness screenshot
|
||||
`codex-clipboard-be3c8df5-ada5-4683-a66d-81cdf31010e7.png`, 1178 × 760 image pixels.
|
||||
It is a section crop; viewport, CSS size and density are unspecified.
|
||||
|
||||
Implementation capture and full-view/focused comparison remain unavailable
|
||||
because browser observation was rejected by the URL security policy. No
|
||||
alternative browser surface was used to bypass that refusal.
|
||||
|
||||
- Typography: inherited Studio font, scale and weight tokens. Rendering remains
|
||||
unverified; source-specific font substitution was not introduced.
|
||||
- Layout: responsive two-column cards, large rounded borders, equal-height bodies,
|
||||
separate footer actions and single-column narrow layouts. Rendered rhythm and
|
||||
scrolling remain unverified.
|
||||
- Color: existing surface, border, text and selected tokens. The selected badge
|
||||
uses the text/page pair. Theme contrast still needs browser inspection.
|
||||
- Assets: supplied product icon library reused for copy; no new raster assets
|
||||
or handcrafted icon geometry.
|
||||
- Content: Standard/Minimal remain the shipped modes; IDs remain canonical.
|
||||
Details, usage and copy remain separate from the card's default selection.
|
||||
- Behavior: card clicks persist a machine-local built-in choice. New local and
|
||||
remote requests carry it explicitly; history and explicit per-task choices
|
||||
retain priority. Old server preferences alone remain inert.
|
||||
|
||||
待浏览器恢复后,对照所选卡片截图检查字体、留白、颜色、图标和文案,并覆盖
|
||||
深浅主题、窄屏、默认状态及展开的使用说明。当前功能测试不能替代视觉验收。
|
||||
|
||||
## Historical palette verification / 历史配色验收
|
||||
|
||||
final result: passed
|
||||
|
||||
Clear Blue and Tea Green implement the two approved colour directions for
|
||||
@@ -85,6 +176,163 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
|
||||
Windows native installer acceptance and hosted CI are separate from these
|
||||
local results. The production build retains its existing large-chunk warning.
|
||||
|
||||
# Composer composition placement / 输入框组合布局
|
||||
|
||||
final result: passed
|
||||
|
||||
The annotated request moves the existing composition selector and management
|
||||
entry beside the run status. It preserves the existing screen, visual tokens
|
||||
and session behavior. / 按标注将组合选择与管理入口移到运行状态旁,沿用现有界面。
|
||||
|
||||
## Evidence / 证据
|
||||
|
||||
- Source visual truth: author-approved composer annotation (1310 × 1826 pixels; browser chrome included, density unspecified). The user attachment remains private; the synthetic baseline below records the same controls.
|
||||
- Matched source capture: `docs/images/agent-compositions/composer-before-light-wide.png`.
|
||||
- Implementation: `docs/images/agent-compositions/composer-light-wide.png`.
|
||||
- Full-view comparison: the matched before/after captures above were opened together, each 1440 × 1000 pixels, 1440 × 1000 CSS px, device scale factor 1. No density resampling.
|
||||
- Focused comparison: composer controls from those captures were compared together at original pixel density; `composer-context-light.png` records the final control arrangement.
|
||||
- Additional captures: `composer-light-390.png`, `composer-dark-390.png` (390 × 844 CSS/pixel dimensions), and `composer-dark-wide.png` (1440 × 1000), all in the same image directory.
|
||||
- State: local Mock preview, Chinese, idle, General selected, existing input draft and missing-key notice. No provider request submitted.
|
||||
|
||||
## Findings / 结论
|
||||
|
||||
No actionable P0/P1/P2 differences remain. / 无需修复的 P0/P1/P2 差异。
|
||||
|
||||
- Typography: existing UI family, 11 px medium-weight controls and status typography retained; long names remain bounded by the selector.
|
||||
- Layout: selector begins 16 px after run status; controls occupy the previously unused top row. The bottom toolbar fits one row at 1440 px. At 390 px the selector, status and management entry remain visible, with document width exactly 390 px.
|
||||
- Colors: existing semantic theme tokens and hover/focus styles reused in both light and dark mode; no new palette.
|
||||
- Assets: logo and icons preserved; no generated or replacement imagery.
|
||||
- Content: composition names, source labels, error notice and management action retained. The placement is the only intentional visual change.
|
||||
- Accessibility: composition controls sit outside `role="status"` and its live announcements. Accessible names and existing lock behavior are retained.
|
||||
|
||||
## Checks / 验证
|
||||
|
||||
- Switched General → Code review → General through the relocated selector.
|
||||
- Opened Agent presets through the relocated management entry, then closed it.
|
||||
- Confirmed the selector is absent from the bottom toolbar in the real DOM.
|
||||
- Browser warning/error log after interaction: empty.
|
||||
- Typecheck and `make check`: passed.
|
||||
- Focused interaction tests: 92 passed; full frontend suite: 632 files, 5,220 tests passed.
|
||||
- Locked-session/new-session flow covered by the existing picker interaction test.
|
||||
|
||||
## Comparison history / 对照记录
|
||||
|
||||
First rendered comparison passed; no P0/P1/P2 visual repair iteration was needed.
|
||||
The matched before/after captures demonstrate the requested move and reduced
|
||||
bottom-toolbar wrapping. / 首轮对照通过,上移后底栏在宽屏减少一行。
|
||||
|
||||
## Limits and next check / 范围
|
||||
|
||||
Browser-owned layout only; native titlebar and shell behavior are unchanged and
|
||||
were not re-tested. No provider execution or new CI result is claimed for this
|
||||
layout edit. / 本次验证浏览器布局,不涉及原生窗口或模型执行。
|
||||
|
||||
Checklist complete: placement, selection, management, live-region separation,
|
||||
light/dark, wide/narrow, typecheck, affected and full frontend tests.
|
||||
|
||||
# Unified composition menu / 统一组合菜单
|
||||
|
||||
final result: passed
|
||||
|
||||
This section supersedes the separate selector/management controls above.
|
||||
The annotation asks to combine those entries without changing the surrounding
|
||||
composer. / 本节替代上述独立选择器与管理按钮,按标注合并为一个入口。
|
||||
|
||||
## Source and capture / 来源与截图
|
||||
|
||||
- Source visual truth: author-approved annotation showing the composition selector and adjacent management button, 452 × 312 pixels; density and full viewport unspecified. The user attachment remains private.
|
||||
- Current-run baseline: `docs/images/agent-compositions/composition-menu-before.png`, 1440 × 1000 CSS/pixel dimensions, scale factor 1.
|
||||
- Implementation: `composition-menu-light-wide.png`, `composition-menu-dark-wide.png` (1440 × 1000) and `composition-menu-light-390.png`, `composition-menu-dark-390.png` (390 × 844), all in the same directory, scale factor 1.
|
||||
- Focused implementation: `composition-menu-detail.png`, 344 × 364 pixels at scale factor 1, including menu and trigger.
|
||||
- Full-view comparison: `composition-menu-before.png` and `composition-menu-light-wide.png` were opened together. Baseline is closed; implementation is open to assess the added menu and unchanged surrounding composer.
|
||||
- Focused comparison: the annotation and `composition-menu-detail.png` were opened together without density resampling. Source typography cannot support a 1:1 pixel comparison; the matched viewport supplies the product typography/layout reference.
|
||||
- State: local Mock preview, Chinese, idle, Code review selected. No provider execution.
|
||||
|
||||
## Flow audit / 流程检查
|
||||
|
||||
1. Read the active composition: healthy after combining the entries. A single trigger shows name and localized source. The separate ellipsis had no visible explanation; its action is now explicit.
|
||||
2. Choose a composition: healthy. Menu rows show capability notes and source, mark the current choice, and close after selection. Evidence: `composition-menu-light-wide.png` and `composition-menu-selected.png`.
|
||||
3. Manage presets: healthy. A separated footer opens the existing management page. Evidence: `composition-menu-manage.png`. Management costs one extra click; frequent switching retains the same click count.
|
||||
|
||||
## Fidelity and interaction / 视觉与交互
|
||||
|
||||
- Typography: existing UI family and weights; 13 px choice titles and 11 px notes. Source labels sit beside names rather than consuming another row.
|
||||
- Spacing: existing composer-menu recipe, 320 px menu width, 12 px radius and 8 px inner padding. At 390 px, the menu spans x=64…384 and the document remains exactly 390 px wide.
|
||||
- Colors: existing surface, overlay, text, muted and focus tokens work in both themes; no new palette.
|
||||
- Assets: existing chevron and brand assets retained; no new image assets.
|
||||
- Copy: localized Built in/Personal/Project labels replace raw source identifiers. Management is a named footer action.
|
||||
- Accessibility: trigger name includes the active composition and lock state; selected rows expose current state. Keyboard arrows skip disabled rows; Enter selects, Escape closes and restores trigger focus. This is scoped interaction evidence, not a full accessibility compliance claim.
|
||||
- Safety of interaction: started/busy conversations never rebind through this menu. The same menu offers explicitly labeled new-conversation actions and management; hosts without creation support still expose management.
|
||||
|
||||
## Iteration and validation / 迭代与验证
|
||||
|
||||
- First inspection found source metadata on an extra line, making each choice unnecessarily tall. The item title now uses a flex row; revised captures show compact name/source pairs. No actionable P0/P1/P2 visual issues remain.
|
||||
- A later resize check found a P2 shared positioning defect: after 390 → 1440 px, menu left remained 88 px while its trigger settled at 570 px. Window resize preceded fold publication and sidebar transition completion.
|
||||
- Shared floating placement now responds to fold publication and transition completion. The revised dark-wide capture and browser rectangles show both left edges at 570 px.
|
||||
- A deterministic real-Picker regression fails without the positioning fix and also checks cleanup after closing.
|
||||
- Verified selection, management, Escape and responsive placement in the browser; warning/error log was empty.
|
||||
- Regression tests cover keyboard focus, distinct action identities, failed/pending selections, locked sessions, management without creation support, equal names from different sources, and deleted bound presets.
|
||||
- Final validation after the resize repair: typecheck passed; frontend full suite passed, 633 files and 5,225 tests. Repository checks passed without increasing a budget.
|
||||
- Native shell behavior and provider execution are outside this layout change; hosted CI and publication status are reported separately in the pull request.
|
||||
|
||||
Checklist complete: one entry, grouped choices/actions, clear current state,
|
||||
retained locks, keyboard access, themes, narrow viewport and regression checks.
|
||||
|
||||
## Studio integration verification / Studio 合并验证
|
||||
|
||||
The upstream integration preserves the top-row Agent composition trigger and
|
||||
the unified selection/management menu. The upstream screen-reader-only run
|
||||
label remains inside the live status region; interactive controls remain
|
||||
outside it. Composition menu text follows the shared text-size setting.
|
||||
|
||||
合并后保留顶部 Agent 组合入口与统一的选择、管理菜单,同时沿用上游运行状态的
|
||||
读屏处理。浏览器复核覆盖组合切换、管理入口,以及菜单展开时从宽屏缩至窄屏
|
||||
的定位与换行。完整前端测试通过:651 个文件、5,342 个用例。
|
||||
|
||||
| Browser | Light | Dark |
|
||||
| --- | --- | --- |
|
||||
| 1440 × 1000 | Capture (local capture) | Capture (local capture) |
|
||||
| 390 × 844 | Capture (local capture) | Capture (local capture) |
|
||||
|
||||
These captures use synthetic Mock data. Native Electron shell behavior was
|
||||
not requalified by this browser integration check.
|
||||
|
||||
## Standard and Minimal modes / 标准与精简模式
|
||||
|
||||
The management page references the supplied Harness card hierarchy while using
|
||||
Studio typography and surface tokens. Only Standard and Minimal are shipped;
|
||||
custom definitions remain separate. Standard retains its existing stable ID.
|
||||
Minimal uses native terminal/image tools and compaction without Cordis plugins.
|
||||
|
||||
管理页保留内置卡片、默认标记、模式说明与独立自定义区域。宽屏并排,390px
|
||||
窄屏纵向排列且可滚动。默认选择与当前会话绑定分离;复制后继续使用原编辑器。
|
||||
入口只展示模式名和箭头,位于左侧;运行状态与 token 位于右侧,来源与锁定
|
||||
信息保留在菜单、提示和无障碍名称中。
|
||||
|
||||
| Browser | Light | Dark |
|
||||
| --- | --- | --- |
|
||||
| 1440 × 1000 | Cards (local capture) | Cards (local capture) |
|
||||
| 390 × 844 | Cards (local capture) | Cards (local capture) |
|
||||
|
||||
Composer idle (local capture) and
|
||||
running (local capture) captures
|
||||
show the compact mode trigger with separate run information. These are browser
|
||||
Mock captures; the source annotation image is not published.
|
||||
|
||||
The full frontend suite passes 651 files and 5,343 tests. Mechanism checks retain
|
||||
explicit custom read-only fixtures for proxy, late-registration and child-agent
|
||||
ceilings. Minimal's terminal is intentionally allowed. Text-only models report
|
||||
an unavailable image dependency instead of silently changing the tool set.
|
||||
|
||||
After the subsequent MCP-follow integration, the full frontend suite passes
|
||||
651 files and 5,347 tests. Provider-boundary and race regressions confirm that
|
||||
automatic server-tool approval cannot replace an existing composition's pinned
|
||||
contract; a new conversation can bind the updated catalog.
|
||||
|
||||
The native-video integration passes 651 frontend files and 5,348 tests. Combined
|
||||
projection regressions retain bindings/video references in stored history while
|
||||
removing local binding metadata and answered video payloads from model requests.
|
||||
|
||||
## Workspace preparation / 工作目录准备
|
||||
|
||||
The workspace palette and preparation states reuse existing typography and
|
||||
@@ -96,3 +344,21 @@ QA passed. Captures remain in
|
||||
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
|
||||
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
|
||||
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
|
||||
|
||||
|
||||
## Maintainer opt-in revision / 默认关闭调整
|
||||
|
||||
Standard and Minimal cards, the left-side name-only picker and right-side run
|
||||
information are preserved. Preferences are labeled as preferences; they do not
|
||||
activate a mode. No selection and Standard remain unbound. Complete replacement
|
||||
is removed from the editor; selected personas supplement first-party guidance.
|
||||
|
||||
保留标准/精简卡片、左侧仅模式名的入口及右侧运行信息。首选项不自动启用;
|
||||
未选择及标准模式不绑定。编辑器移除完整替换开关,人设仅补充内置指令。
|
||||
|
||||
Per maintainer request, composition screenshots are local evidence and are not
|
||||
committed. Historical capture references above describe earlier verification.
|
||||
Current browser checks and hosted workflow results are reported separately.
|
||||
|
||||
按维护者要求,组合截图仅保留为本地证据,不提交到仓库。上方记录为历史验证;
|
||||
当前浏览器检查与远端工作流结论分开报告。
|
||||
@@ -135,14 +135,16 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
}
|
||||
}
|
||||
|
||||
// installable reports whether an artifact installs itself rather than expecting
|
||||
// the reader to place it: a disk image to drag from, an installer to run, a
|
||||
// package for dpkg.
|
||||
// installable reports whether an artifact is a download a person is offered: a
|
||||
// disk image to drag from, an installer to run, a package for dpkg or rpm, or an
|
||||
// AppImage to run in place. The AppImage is a download only; no native_packages
|
||||
// key names it, so no client replaces one.
|
||||
func installable(name string) bool {
|
||||
return strings.HasSuffix(name, ".dmg") ||
|
||||
strings.HasSuffix(name, "-installer.exe") ||
|
||||
strings.HasSuffix(name, ".deb") ||
|
||||
strings.HasSuffix(name, ".rpm")
|
||||
strings.HasSuffix(name, ".rpm") ||
|
||||
strings.HasSuffix(name, ".AppImage")
|
||||
}
|
||||
|
||||
// macArchiveKeys maps a macOS archive to the platforms it can install. The
|
||||
|
||||
@@ -340,3 +340,36 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An AppImage is a download a person is offered and nothing an updater
|
||||
// replaces: it must not appear as a platform asset or a native package, and
|
||||
// adding it must not move any entry a shipped client reads.
|
||||
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
|
||||
a, ok := m.Downloads[name]
|
||||
if !ok || a.Sig != a.URL+".minisig" {
|
||||
t.Errorf("downloads[%s] = %+v, %v; want a signed download", name, a, ok)
|
||||
}
|
||||
}
|
||||
if len(m.NativePackages) != 1 || !strings.HasSuffix(m.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
|
||||
t.Errorf("native packages = %+v, want only the .deb", m.NativePackages)
|
||||
}
|
||||
if len(m.Platforms) != 0 {
|
||||
t.Errorf("platforms = %+v: an AppImage must not resolve as an update", m.Platforms)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
Binary file not shown.
|
After Width: | Height: | Size: 927 B |
@@ -99,11 +99,19 @@ linux:
|
||||
target:
|
||||
- deb
|
||||
- rpm
|
||||
# Download only: it does not update itself, and nothing here installs it.
|
||||
- AppImage
|
||||
icon: assets/icon.png
|
||||
category: Development
|
||||
maintainer: esengine <[email protected]>
|
||||
artifactName: ReasonixStudio-linux-${arch}.${ext}
|
||||
|
||||
appImage:
|
||||
# Stated rather than inherited: the release vocabulary is the same for every
|
||||
# Linux file, and canonical-artifact-names.js turns whatever architecture
|
||||
# spelling electron-builder puts here (x64, x86_64) into amd64 or fails.
|
||||
artifactName: ReasonixStudio-linux-${arch}.${ext}
|
||||
|
||||
deb:
|
||||
# Named, not defaulted. electron-builder takes the package name from
|
||||
# package.json's `name` unless told otherwise, which would publish
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"browser-resize-live": "electron test/browser_resize_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
"image-live": "electron test/image_live.js",
|
||||
"image-pane-live": "electron test/image_pane_live.js",
|
||||
"sidepanel-live": "electron test/sidepanel_live.js",
|
||||
@@ -22,12 +25,13 @@
|
||||
"ime-live": "electron test/ime_live.js",
|
||||
"freeze-live": "electron test/freeze_live.js",
|
||||
"windowstate-live": "electron test/windowstate_live.js",
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
"computer-live": "electron test/computer_live.js",
|
||||
"launcher-live": "node test/launcher_live.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.7.0",
|
||||
"electron": "44.7.2",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.64.0"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
|
||||
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
|
||||
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
|
||||
// macOS tints a template image itself, so the menu-bar glyph is the mark alone,
|
||||
// black on transparent, framed square around it with a one-pixel margin at 18px.
|
||||
const TEMPLATE_VIEWBOX = 'viewBox="277 277 470 470"';
|
||||
const TEMPLATE_SCALES = [[18, ""], [36, "@2x"]];
|
||||
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
|
||||
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
|
||||
// Linux keep the full-canvas artwork the platform templates expect.
|
||||
@@ -79,6 +83,13 @@ async function main() {
|
||||
for (const [scale, suffix] of TRAY_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
|
||||
}
|
||||
const markSvg = svg
|
||||
.replace(/<rect [^>]*\/>\s*/g, "")
|
||||
.replace('fill="url(#gold)"', 'fill="#000"')
|
||||
.replace('viewBox="0 0 1024 1024"', TEMPLATE_VIEWBOX);
|
||||
for (const [size, suffix] of TEMPLATE_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `trayTemplate${suffix}.png`), await render(page, markSvg, size));
|
||||
}
|
||||
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
|
||||
const icoPngs = [];
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
|
||||
|
||||
Generated
+14
-14
@@ -9,14 +9,14 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.7.0
|
||||
version: 44.7.0([email protected])
|
||||
specifier: 44.7.2
|
||||
version: 44.7.2([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
playwright:
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
specifier: 1.64.1
|
||||
version: 1.64.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
|
||||
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].0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected].0([email protected]):
|
||||
[email protected].2([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
@@ -1942,11 +1942,11 @@ snapshots:
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected].0: {}
|
||||
[email protected].1: {}
|
||||
|
||||
[email protected].0:
|
||||
[email protected].1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.0
|
||||
playwright-core: 1.64.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use strict";
|
||||
const { handOver } = require("./launcher");
|
||||
|
||||
// The acts the kernel asks of the application, because only this process can do
|
||||
// them. relaunch and quit are a handover and arrive in that order, so arming
|
||||
@@ -11,6 +12,7 @@ function actHandler({ app, showWindow, log = () => {} }) {
|
||||
return (act) => {
|
||||
if (act === "show" || act === "relaunch" || act === "quit") log(`kernel act: ${act}`);
|
||||
if (act === "relaunch") {
|
||||
handOver();
|
||||
app.relaunch();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class BrowserViews {
|
||||
}
|
||||
onEvent(method, params);
|
||||
});
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
this.watchLoads(targetId, entry);
|
||||
let closed = false;
|
||||
@@ -140,6 +140,15 @@ class BrowserViews {
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => {
|
||||
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
|
||||
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
|
||||
try {
|
||||
params = scaleInput(method, params, entry.scale);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
// A capture puts the page's emulation back without its scale.
|
||||
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
|
||||
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
|
||||
entry.synthUntil = Date.now() + 250;
|
||||
return this.wake.run(() => contents.debugger.sendCommand(method, params));
|
||||
@@ -164,7 +173,7 @@ class BrowserViews {
|
||||
// which during a turn is constantly, and nearly always the same rectangle:
|
||||
// re-adding the view and setting the bounds it already has is work for
|
||||
// every one of those.
|
||||
if (this.shown === targetId && sameRect(entry.at, at)) return;
|
||||
if (this.shown === targetId && sameRect(entry.panel, at)) return;
|
||||
this.placed++;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
@@ -176,9 +185,49 @@ class BrowserViews {
|
||||
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.attached = true;
|
||||
entry.panel = at;
|
||||
this.fit(entry);
|
||||
this.keepKeyboard();
|
||||
}
|
||||
|
||||
// fit puts the view where its page is drawn whole and tells the page how it is
|
||||
// scaled; a page parked out of sight is not scaled. force sends the scale again
|
||||
// though it has not changed, for a capture that put the page's emulation back.
|
||||
fit(entry, force = false) {
|
||||
if (!entry.panel) return Promise.resolve();
|
||||
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
this.keepKeyboard();
|
||||
return this.scaled(entry, scale, force);
|
||||
}
|
||||
|
||||
scaled(entry, scale, force = false) {
|
||||
if (!entry.override || (entry.scale === scale && !force)) return Promise.resolve();
|
||||
entry.scale = scale;
|
||||
return this.override(entry).catch(() => {});
|
||||
}
|
||||
|
||||
override(entry) {
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { ...entry.override, scale: entry.scale });
|
||||
}
|
||||
|
||||
// emulate records the viewport a page was told to lay out in, which the view
|
||||
// is then fitted to; null is the page going back to the size of its view.
|
||||
async emulate(entry, params) {
|
||||
entry.override = sized(params) ? { ...params } : null;
|
||||
if (!entry.override) {
|
||||
entry.scale = 1;
|
||||
if (entry.panel) this.fit(entry);
|
||||
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
|
||||
}
|
||||
entry.scale = 1;
|
||||
if (entry.panel) {
|
||||
const { at, scale } = placement(entry.panel, entry.override);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
entry.scale = scale;
|
||||
}
|
||||
return this.override(entry);
|
||||
}
|
||||
|
||||
// Attaching a view can leave nobody holding the keyboard, and the platform
|
||||
@@ -253,7 +302,11 @@ class BrowserViews {
|
||||
putAway(view, entry) {
|
||||
const { height } = this.win.getContentBounds();
|
||||
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
|
||||
if (entry) entry.at = null;
|
||||
if (entry) {
|
||||
entry.at = null;
|
||||
entry.panel = null;
|
||||
void this.scaled(entry, 1);
|
||||
}
|
||||
}
|
||||
|
||||
control(targetId, action) {
|
||||
@@ -420,6 +473,44 @@ async function loadResult(contents, url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Where a page's view sits in the rectangle the panel gave it. A page laid out
|
||||
// in a viewport of its own is shown whole: scaled down to fit and never up,
|
||||
// centred, with the view sized to exactly what it draws.
|
||||
const sized = (v) => !!v && Number.isFinite(v.width) && Number.isFinite(v.height) && v.width > 0 && v.height > 0;
|
||||
|
||||
function placement(panel, emulated) {
|
||||
if (!sized(emulated)) return { at: panel, scale: 1 };
|
||||
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
|
||||
const width = Math.max(1, Math.round(emulated.width * scale));
|
||||
const height = Math.max(1, Math.round(emulated.height * scale));
|
||||
const x = panel.x + Math.round((panel.width - width) / 2);
|
||||
const y = panel.y + Math.round((panel.height - height) / 2);
|
||||
return { at: { x, y, width, height }, scale };
|
||||
}
|
||||
|
||||
// Input that carries a point. The page reads it in the viewport it was told it
|
||||
// has, the view draws that viewport scaled, and a point meant for the page lands
|
||||
// where it is drawn only after the same scaling. The mouse is measured to; the
|
||||
// rest are not, and a point that silently lands elsewhere is worse than a
|
||||
// refusal, so they are refused while the page is scaled.
|
||||
const POINTED_INPUT = new Set([
|
||||
"Input.dispatchTouchEvent",
|
||||
"Input.dispatchDragEvent",
|
||||
"Input.emulateTouchFromMouseEvent",
|
||||
"Input.synthesizeTapGesture",
|
||||
"Input.synthesizeScrollGesture",
|
||||
"Input.synthesizePinchGesture",
|
||||
]);
|
||||
|
||||
function scaleInput(method, params, scale) {
|
||||
if (scale === 1) return params;
|
||||
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
|
||||
if (POINTED_INPUT.has(method)) {
|
||||
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function sameRect(a, b) {
|
||||
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
@@ -428,4 +519,4 @@ function validRect(rect) {
|
||||
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
|
||||
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
// The kernel's gate answers every request without the launch credential with
|
||||
// 403, and a cookie belongs to the host, not the port, so anything that removes it (an
|
||||
// eviction, a page clearing cookies for 127.0.0.1) leaves the window refused
|
||||
// until a relaunch. The shell holds the token, so it puts the cookie back.
|
||||
function keepCredential(ses, cookie, log) {
|
||||
const host = new URL(cookie.url).hostname;
|
||||
const restore = (cause) => {
|
||||
log(`credential cookie removed (${cause}); restoring`);
|
||||
ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
|
||||
};
|
||||
const onChanged = (_event, changed, cause, removed) => {
|
||||
if (!removed || changed.name !== cookie.name || cause === "overwrite") return;
|
||||
if (changed.domain.replace(/^\./, "") !== host) return;
|
||||
restore(cause);
|
||||
};
|
||||
ses.cookies.on("changed", onChanged);
|
||||
return () => ses.cookies.removeListener("changed", onChanged);
|
||||
}
|
||||
|
||||
module.exports = { keepCredential };
|
||||
@@ -62,11 +62,14 @@ async function startEmbedded(executable, contract, onStderr = () => {}, spawnDri
|
||||
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;
|
||||
if (!current.isSocket()) throw new Error("Invalid private CUA socket");
|
||||
// The driver binds before chmod: existence alone does not establish readiness.
|
||||
if ((current.mode & 0o777) === 0o600) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
// A development shell belongs to the process that launched it: `pnpm start`,
|
||||
// a live test, an agent's background job. Killed outright, that launcher takes
|
||||
// nothing with it, so the shell has to notice it is gone and leave on its own.
|
||||
// Packaged shells, and shells a relaunch handed over, belong to nobody.
|
||||
const HANDED_OVER = "REASONIX_STUDIO_HANDED_OVER";
|
||||
const EVERY_MS = 1000;
|
||||
|
||||
// handOver marks the next instance as nobody's. The launcher waits on this
|
||||
// process and exits with it, so a relaunched shell would otherwise read its own
|
||||
// start as the launcher's death.
|
||||
function handOver(env = process.env) {
|
||||
env[HANDED_OVER] = "1";
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return err?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
// POSIX gives an orphan a new parent, which settles it. Windows leaves the dead
|
||||
// pid as the parent, so there it is probed instead, and a pid already reused
|
||||
// leaves the shell running as it would without this watch.
|
||||
function watchLauncher({ packaged, env = process.env, onLost, parent = () => process.ppid, alive: isAlive = alive, everyMs = EVERY_MS }) {
|
||||
const handedOver = env[HANDED_OVER] === "1";
|
||||
delete env[HANDED_OVER];
|
||||
const launcher = parent();
|
||||
if (packaged || handedOver || launcher <= 1) return { dispose() {} };
|
||||
const timer = setInterval(() => {
|
||||
if (parent() === launcher && isAlive(launcher)) return;
|
||||
clearInterval(timer);
|
||||
onLost();
|
||||
}, everyMs);
|
||||
timer.unref?.();
|
||||
return { dispose: () => clearInterval(timer) };
|
||||
}
|
||||
|
||||
// consoleEcho writes to a console the launcher may already have closed. An
|
||||
// EPIPE nobody handles opens Electron's modal error box, which stops the main
|
||||
// process; shell.log and host.log keep every line, so a failed or backed-up
|
||||
// console is dropped rather than retried.
|
||||
function consoleEcho(stream) {
|
||||
let failed = false;
|
||||
let blocked = false;
|
||||
stream.on("error", () => {
|
||||
failed = true;
|
||||
});
|
||||
stream.on("drain", () => {
|
||||
blocked = false;
|
||||
});
|
||||
return (chunk) => {
|
||||
if (failed || blocked || stream.destroyed || stream.writableEnded) return;
|
||||
try {
|
||||
blocked = !stream.write(chunk);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { watchLauncher, handOver, consoleEcho };
|
||||
@@ -13,6 +13,7 @@ const { showStarting } = require("./starting");
|
||||
const { StudioHost } = require("./hostclient");
|
||||
const { installTray } = require("./tray");
|
||||
const { instanceID, profileFor } = require("./instance");
|
||||
const { watchLauncher, consoleEcho } = require("./launcher");
|
||||
const { installApplicationMenu, installContextMenu } = require("./menu");
|
||||
const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
@@ -32,10 +33,12 @@ const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { processMemoryHandler } = require("./procmem");
|
||||
const { captureHandler } = require("./capture");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { raise } = require("./raise");
|
||||
const { keepCredential } = require("./credential");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
@@ -72,6 +75,7 @@ const computerDriver = layout.computerDriver(where);
|
||||
const pageDir = layout.pageDir(where);
|
||||
|
||||
const acts = actHandler({ app, showWindow: () => showWindow(), log: (line) => logs?.shell.line(line) });
|
||||
const toConsole = consoleEcho(process.stderr);
|
||||
let kernel = null;
|
||||
let cua = null;
|
||||
let win = null;
|
||||
@@ -124,7 +128,7 @@ async function launchKernel(args) {
|
||||
},
|
||||
onStderr: (text) => {
|
||||
logs.host.raw(text);
|
||||
process.stderr.write(text);
|
||||
toConsole(text);
|
||||
},
|
||||
onExit: (code, signal) => {
|
||||
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
|
||||
@@ -139,6 +143,7 @@ async function launchKernel(args) {
|
||||
onAct: (act) => acts(act),
|
||||
});
|
||||
const current = kernel;
|
||||
logs.shell.line(`host: pid ${current.child.pid ?? "(none)"}`);
|
||||
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
|
||||
current.child.stderr.on("close", () => logs.host.flush());
|
||||
try {
|
||||
@@ -280,7 +285,7 @@ function cleanUpLegacyInstalls() {
|
||||
// it, Strict because no cross-site navigation ever needs to carry it, and no
|
||||
// expiry so it dies with this session rather than outliving the launch.
|
||||
async function armCredential(ready) {
|
||||
await session.defaultSession.cookies.set({
|
||||
const cookie = {
|
||||
url: ready.origin + "/",
|
||||
name: TOKEN_COOKIE,
|
||||
value: ready.token,
|
||||
@@ -288,7 +293,9 @@ async function armCredential(ready) {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
};
|
||||
await session.defaultSession.cookies.set(cookie);
|
||||
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
|
||||
}
|
||||
|
||||
// The close button hides where an icon can bring the window back, and quits
|
||||
@@ -392,6 +399,7 @@ nativeTheme.on("updated", () => {
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
|
||||
|
||||
ipcMain.handle("window:unread-badge", (event, unread) => {
|
||||
const target = fromWindow(event);
|
||||
@@ -508,7 +516,7 @@ if (gpuLaunch.launchedOff) app.disableHardwareAcceleration();
|
||||
logs = openLogs(app.getPath("userData"));
|
||||
logs.shell.line(
|
||||
`shell: start version=${app.getVersion()} packaged=${app.isPackaged} platform=${process.platform}/${process.arch} ` +
|
||||
`instance=${identity || "(unknown)"} argv=${JSON.stringify(redactArgv(process.argv))}`,
|
||||
`instance=${identity || "(unknown)"} pid=${process.pid} argv=${JSON.stringify(redactArgv(process.argv))}`,
|
||||
);
|
||||
const primary = app.requestSingleInstanceLock();
|
||||
if (!primary) {
|
||||
@@ -519,6 +527,13 @@ if (!primary) {
|
||||
app.on("second-instance", showWindow);
|
||||
// macOS reopens an existing app through activation, without a second process.
|
||||
app.on("activate", showWindow);
|
||||
watchLauncher({
|
||||
packaged: app.isPackaged,
|
||||
onLost: () => {
|
||||
logs.shell.line("shell: launcher gone; quitting");
|
||||
app.quit();
|
||||
},
|
||||
});
|
||||
grants = stripPackageGrants(hostBinary, { ...where, execPath: process.execPath });
|
||||
if (grants?.stripped.length) {
|
||||
const note = `removed app-package grants that stop sandboxed processes loading: ${grants.stripped.join(", ")}`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use strict";
|
||||
const { handOver } = require("./launcher");
|
||||
|
||||
const X11 = "--ozone-platform=x11";
|
||||
|
||||
@@ -33,6 +34,7 @@ function wantsX11(platform, env, args) {
|
||||
function relaunchForOzonePlatform(app, { platform, env, argv }) {
|
||||
const args = argv.slice(1);
|
||||
if (!wantsX11(platform, env, args)) return false;
|
||||
handOver(env);
|
||||
app.relaunch({ args: [X11, ...args] });
|
||||
app.exit(0);
|
||||
return true;
|
||||
|
||||
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
captureWindow: () => ipcRenderer.invoke("window:capture"),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
// The window's processes as type and two sizes, for the memory a report may carry.
|
||||
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
|
||||
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)),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
const TYPES = { Tab: "renderer", Browser: "browser", GPU: "gpu" };
|
||||
// The kernel adds its own row, and a report holds a dozen.
|
||||
const MAX_ROWS = 11;
|
||||
|
||||
const kb = (v) => (Number.isFinite(v) && v > 0 ? Math.min(Math.round(v), 2 ** 31) : 0);
|
||||
|
||||
// What app.getAppMetrics says, cut down to what a report may carry: a fixed
|
||||
// type per process and two whole numbers. No pid, name, path or command line
|
||||
// is read, so none can be sent.
|
||||
function processMemoryRows(metrics) {
|
||||
return (Array.isArray(metrics) ? metrics : []).slice(0, MAX_ROWS).map((m) => ({
|
||||
process: TYPES[m?.type] ?? "utility",
|
||||
workingSetKB: kb(m?.memory?.workingSetSize),
|
||||
privateKB: kb(m?.memory?.privateBytes),
|
||||
}));
|
||||
}
|
||||
|
||||
function processMemoryHandler(app, fromWindow) {
|
||||
return (event) => {
|
||||
if (!fromWindow(event)) return [];
|
||||
try {
|
||||
return processMemoryRows(app.getAppMetrics());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { processMemoryRows, processMemoryHandler };
|
||||
@@ -2,8 +2,8 @@
|
||||
const { BrowserWindow } = require("electron");
|
||||
|
||||
const TEXT = {
|
||||
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
|
||||
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
|
||||
en: ["Reasonix Studio is starting", "Waiting for the local service to be ready. This window will close automatically."],
|
||||
zh: ["Reasonix Studio 正在启动", "正在等待本地服务就绪,完成后此窗口会自动关闭。"],
|
||||
};
|
||||
|
||||
function startingPage(locale) {
|
||||
|
||||
@@ -19,10 +19,12 @@ function trayAsset(scaleFactor) {
|
||||
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
|
||||
}
|
||||
|
||||
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
|
||||
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
|
||||
// platforms choose among the @Nx files themselves.
|
||||
// macOS draws a Template-suffixed pair (black on transparent) in the menu bar's
|
||||
// own tint. Windows rebuilds the tray icon from the 1x bitmap, resizing it to
|
||||
// the small icon metric, so the file is loaded as 1x pixels of exactly that
|
||||
// size. Other platforms choose among the @Nx files themselves.
|
||||
function trayImage(nativeImage, scaleFactor) {
|
||||
if (process.platform === "darwin") return nativeImage.createFromPath(path.join(ASSETS, "trayTemplate.png"));
|
||||
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
|
||||
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
|
||||
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
|
||||
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker } = require("./livekit");
|
||||
|
||||
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r,.l{position:absolute;top:10px;background:#111;color:#fff;padding:8px}.r{right:10px}.l{left:10px;top:60px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span><span class=l id=lc onclick="document.title='hit'">left column</span></div>`;
|
||||
// Its own profile, so another Electron run on this machine does not hold this one back.
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(require("node:os").tmpdir(), "rx-fit-live-")));
|
||||
|
||||
const { check, failures } = checker();
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
await wait(1500);
|
||||
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
const shot = (name) => {
|
||||
if (!process.env.SHOTS) return;
|
||||
fs.mkdirSync(process.env.SHOTS, { recursive: true });
|
||||
win.focus();
|
||||
const b = win.getBounds();
|
||||
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
|
||||
};
|
||||
const click = async (id = "rc") => {
|
||||
await evalIn("document.title='reset'");
|
||||
const r = JSON.parse(await evalIn(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
|
||||
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
|
||||
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
|
||||
}
|
||||
await wait(250);
|
||||
return evalIn("document.title");
|
||||
};
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
|
||||
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const fit = view.getBounds();
|
||||
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
|
||||
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
|
||||
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
|
||||
shot("fit-in-panel");
|
||||
|
||||
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
|
||||
await wait(600);
|
||||
const narrower = view.getBounds();
|
||||
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
|
||||
check("and the click still lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
|
||||
await wait(600);
|
||||
const roomy = view.getBounds();
|
||||
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
|
||||
check("and the click lands at full size", (await click()) === "hit");
|
||||
|
||||
views.hide();
|
||||
await wait(600);
|
||||
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
|
||||
|
||||
views.show(page.targetId, panel);
|
||||
await wait(600);
|
||||
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
|
||||
|
||||
const small = { x: 20, y: 20, width: 900, height: 700 };
|
||||
views.show(page.targetId, small);
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 600, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const phone = view.getBounds();
|
||||
check("a viewport smaller than the panel is shown at its own size, centred, not in the panel's corner", phone.width === 390 && phone.height === 600 && phone.x === 20 + (900 - 390) / 2 && phone.y === 20 + (700 - 600) / 2, JSON.stringify(phone));
|
||||
check("and a click on it lands", (await click("lc")) === "hit");
|
||||
views.show(page.targetId, panel);
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
const taken = await page.send("Page.captureScreenshot", { format: "png" });
|
||||
const png = Buffer.from(taken.data, "base64");
|
||||
check("the agent's screenshot is the size of the viewport it asked for", png.readUInt32BE(16) === 1280 && png.readUInt32BE(20) === 900, `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`);
|
||||
await wait(600);
|
||||
check("after the agent takes a screenshot the page is still fitted and a click still lands", (await click()) === "hit");
|
||||
|
||||
await page.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(600);
|
||||
check("a zero-sized override is no override: the page fills the panel and a click lands", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560 && (await click("lc")) === "hit", JSON.stringify(view.getBounds()));
|
||||
|
||||
await page.send("Emulation.clearDeviceMetricsOverride", {});
|
||||
await wait(600);
|
||||
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
|
||||
shot("cleared");
|
||||
|
||||
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
// Local html previews, an oversized image document and a PDF in the built-in browser, each with and without an
|
||||
// agent viewport: the view must be filled by the page, measured on the pixels, not just sized right.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
const { checker } = require("./livekit");
|
||||
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-fit-pages-")));
|
||||
const { check, failures } = checker();
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const PANEL = { x: 380, y: 60, width: 560, height: 620 };
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-pages-"));
|
||||
|
||||
function png(w, h, rgb) {
|
||||
const zlib = require("node:zlib");
|
||||
const crc = (buf) => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } let r = 0xffffffff; for (const b of buf) r = t[(r ^ b) & 255] ^ (r >>> 8); return (r ^ 0xffffffff) >>> 0; };
|
||||
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
||||
const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
|
||||
return Buffer.concat([Buffer.from("\x89PNG\r\n\x1a\n", "binary"), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, "big.png"), png(3200, 1800, [37, 99, 235]));
|
||||
fs.copyFileSync("/tmp/fl/preview.html", path.join(dir, "preview.html"));
|
||||
fs.copyFileSync("/tmp/fl/page.pdf", path.join(dir, "page.pdf"));
|
||||
|
||||
// Share of the view's sampled pixels that are the near-white or light-grey empty background of an unfilled view.
|
||||
async function emptyShare(view) {
|
||||
const img = await view.webContents.capturePage();
|
||||
const { width, height } = img.getSize();
|
||||
const bm = img.toBitmap();
|
||||
let empty = 0, blue = 0, n = 0;
|
||||
for (let y = 2; y < height - 2; y += Math.max(1, Math.floor(height / 40))) {
|
||||
for (let x = 2; x < width - 2; x += Math.max(1, Math.floor(width / 40))) {
|
||||
const i = (y * width + x) * 4;
|
||||
const b = bm[i], g = bm[i + 1], r = bm[i + 2];
|
||||
n++;
|
||||
if (Math.min(r, g, b) > 225 && Math.max(r, g, b) - Math.min(r, g, b) < 12) empty++;
|
||||
if (b > 180 && r < 90 && g < 150) blue++;
|
||||
}
|
||||
}
|
||||
return { share: empty / n, blue: blue / n, width, height };
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd'>app");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const cases = [
|
||||
["local html preview, no override", "preview.html", null],
|
||||
["local html preview, 1280x900 override", "preview.html", { width: 1280, height: 900 }],
|
||||
["local html preview, oversized 3200x1800 override", "preview.html", { width: 3200, height: 1800 }],
|
||||
["3200x1800 image document, no override", "big.png", null],
|
||||
["3200x1800 image document, 3200x1800 override", "big.png", { width: 3200, height: 1800 }],
|
||||
["PDF, no override", "page.pdf", null],
|
||||
["PDF, 1280x900 override", "page.pdf", { width: 1280, height: 900 }],
|
||||
];
|
||||
for (const [name, file, override] of cases) {
|
||||
const page = views.create({ partition: "p" + Math.random(), url: "file://" + path.join(dir, file), onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
|
||||
const entry = views.entries.get(page.targetId);
|
||||
await wait(file.endsWith(".pdf") ? 3500 : 1500);
|
||||
views.show(page.targetId, PANEL);
|
||||
await wait(500);
|
||||
if (override) await page.send("Emulation.setDeviceMetricsOverride", { ...override, deviceScaleFactor: 0, mobile: false });
|
||||
await wait(file.endsWith(".pdf") ? 2500 : 1200);
|
||||
const at = entry.view.getBounds();
|
||||
if (process.env.SHOTS) { fs.mkdirSync(process.env.SHOTS, { recursive: true }); fs.writeFileSync(path.join(process.env.SHOTS, name.replace(/[^a-z0-9]+/gi, "-") + ".png"), (await entry.view.webContents.capturePage()).toPNG()); }
|
||||
const m = await emptyShare(entry.view);
|
||||
const inside = at.x >= PANEL.x && at.y >= PANEL.y && at.x + at.width <= PANEL.x + PANEL.width && at.y + at.height <= PANEL.y + PANEL.height;
|
||||
const shown = file.endsWith(".html") ? m.share < 0.15 : m.share < 0.15 && m.blue >= (file.endsWith(".png") ? 0.5 : 0.05);
|
||||
const detail = `view ${JSON.stringify(at)}, empty ${(m.share * 100).toFixed(0)}%, page-coloured ${(m.blue * 100).toFixed(0)}% of ${m.width}x${m.height}`;
|
||||
console.log(" measured:", name, detail);
|
||||
check(`${name}: the view sits inside the panel and the page is drawn in it`, inside && shown, detail);
|
||||
views.close?.(page.targetId);
|
||||
}
|
||||
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
// A page the agent lays out at a viewport wider than the pane has to stay whole for the
|
||||
// person watching: the view shows all of it, scaled to the pane, not its left part.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app } = require("electron");
|
||||
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
|
||||
|
||||
const PAGE = `<!doctype html><title>Wide</title><body style="margin:0">
|
||||
<div style="width:100vw;height:100vh;background:linear-gradient(90deg,#e33,#33e);color:#fff;font:48px sans-serif">
|
||||
<span style="position:absolute;left:12px;top:12px">LEFT</span>
|
||||
<span style="position:absolute;right:12px;top:12px">RIGHT</span>
|
||||
<button id="go" style="position:absolute;right:40px;top:300px;font:32px sans-serif" onclick="document.title='clicked'">Go</button></div>`;
|
||||
|
||||
const { check, failures } = checker();
|
||||
const SIZE = { width: 1440, height: 1000 };
|
||||
|
||||
async function main() {
|
||||
const page = await listen((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(PAGE);
|
||||
});
|
||||
let calls = 0;
|
||||
const { handler, requests } = scriptedModel((tools) => {
|
||||
calls++;
|
||||
if (calls === 1) return { name: "browser_open", arguments: { url: `${page.url}/` } };
|
||||
if (calls === 2) return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "resize", ...SIZE }] } };
|
||||
if (calls === 3) return { name: "browser_read", arguments: { tab: "t1", what: "screenshot" } };
|
||||
if (calls === 4) {
|
||||
const ref = (tools.join("\n").match(/button "Go" \[(e\d+)\]/) || [])[1];
|
||||
return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "click", ref }] } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const model = await listen(handler);
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-live-"));
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-ws-"));
|
||||
process.env.REASONIX_HOME = home;
|
||||
seedHome(home, model.url, workspace);
|
||||
const { current } = require("../src/main.js");
|
||||
|
||||
try {
|
||||
const win = await settledWindow();
|
||||
const { client } = current();
|
||||
const runtimes = await until("a pane", async () => {
|
||||
const list = await client.json("GET", "/runtimes");
|
||||
return Array.isArray(list) && list.length ? list : null;
|
||||
});
|
||||
await client.request("POST", `${runtimes[0].base}/submit`, { input: "open the wide page and lay it out wide" });
|
||||
await until("the scripted turn", async () => requests.length >= 5, 60000);
|
||||
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
|
||||
const guest = await until("a page view", async () => guests()[0]);
|
||||
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null));
|
||||
await wait(1500);
|
||||
const bounds = guest.getBounds();
|
||||
const inner = await guest.webContents.executeJavaScript("[innerWidth, innerHeight]");
|
||||
check("the page is laid out at the viewport the agent asked for", inner[0] === SIZE.width && inner[1] === SIZE.height, inner);
|
||||
const draw = (name) => {
|
||||
const file = path.join(os.tmpdir(), name);
|
||||
try {
|
||||
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], file]);
|
||||
process.stdout.write(` screenshot: ${file}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(` (no screenshot: ${err.message})\n`);
|
||||
}
|
||||
};
|
||||
const shown = await guest.webContents.capturePage();
|
||||
const size = shown.getSize();
|
||||
const px = shown.toBitmap();
|
||||
const at = (x, y) => { const i = (y * size.width + x) * 4; return { b: px[i], g: px[i + 1], r: px[i + 2] }; };
|
||||
const edge = at(size.width - 6, 6);
|
||||
check("the right edge of the page is what the view draws at its right edge", edge.b > edge.r, edge);
|
||||
check("the agent's click on a control at the right of the page landed", (await guest.webContents.getTitle()) === "clicked", await guest.webContents.getTitle());
|
||||
draw("rx-resize-live.png");
|
||||
const taken = await guest.webContents.debugger.sendCommand("Page.captureScreenshot", { format: "png" });
|
||||
const dims = require("electron").nativeImage.createFromBuffer(Buffer.from(taken.data, "base64")).getSize();
|
||||
process.stdout.write(` agent screenshot ${JSON.stringify(dims)}\n`);
|
||||
check("a screenshot taken the way the agent takes one shows the page at the size it asked for", dims.width === SIZE.width && dims.height === SIZE.height, dims);
|
||||
process.stdout.write(` bounds ${JSON.stringify(bounds)} inner ${JSON.stringify(inner)}\n`);
|
||||
} catch (err) {
|
||||
check("the run completed", false, String(err && err.message ? err.message : err));
|
||||
}
|
||||
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
app.whenReady().then(main);
|
||||
@@ -1,12 +1,17 @@
|
||||
"use strict";
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
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 deferred = args.includes("--defer-permissions");
|
||||
process.umask(deferred ? 0o022 : 0o177);
|
||||
const server = net.createServer(peer => {
|
||||
if ((fs.statSync(socket).mode & 0o777) !== 0o600) process.exit(2);
|
||||
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.on("message", message => {
|
||||
if (message === "secure") fs.chmodSync(socket, 0o600);
|
||||
});
|
||||
process.stdin.resume();
|
||||
process.stdin.on("end", () => server.close(() => process.exit(0)));
|
||||
@@ -0,0 +1,130 @@
|
||||
// Drives the real shell and kernel through the ways a development launch ends:
|
||||
// its launcher killed outright, before and after the handshake, its console
|
||||
// closed underneath it, and an ordinary stop. Each runs in a throwaway data home.
|
||||
// Run `pnpm build:host` first, then `pnpm launcher-live`.
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const { hostBinary } = require("../src/layout.js");
|
||||
const host = hostBinary({ packaged: false, dirname: path.join(appDir, "src"), platform: process.platform, env: process.env });
|
||||
const cli = path.join(path.dirname(require.resolve("electron")), "cli.js");
|
||||
if (!existsSync(host)) throw new Error(`no kernel at ${host}; run pnpm build:host first`);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const alive = (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
async function until(predicate, what, ms) {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(what);
|
||||
}
|
||||
|
||||
// userData is <platform app data>/Reasonix Studio/<instance id>, and the id is
|
||||
// the kernel's answer for this data home.
|
||||
function profileDir(env) {
|
||||
const id = execFileSync(host, ["-instance-id"], { env, encoding: "utf8" }).trim();
|
||||
const base =
|
||||
process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support")
|
||||
: process.platform === "win32" ? process.env.APPDATA
|
||||
: process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
return path.join(base, "Reasonix Studio", id);
|
||||
}
|
||||
|
||||
async function scenario(name, { kernel = host, end }) {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "reasonix-launcher-live-"));
|
||||
const page = path.join(home, "page");
|
||||
mkdirSync(page);
|
||||
writeFileSync(path.join(page, "index.html"), "<!doctype html><title>launcher live</title>");
|
||||
const env = { ...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: path.join(home, "state"), REASONIX_STUDIO_PAGE: page, REASONIX_STUDIO_HOST: kernel };
|
||||
const profile = profileDir(env);
|
||||
const shellLog = () => {
|
||||
try {
|
||||
return readFileSync(path.join(profile, "logs", "shell.log"), "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const launcher = spawn(process.execPath, [cli, appDir], { cwd: appDir, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
launcher.stdout.resume();
|
||||
launcher.stderr.resume();
|
||||
const pids = [];
|
||||
try {
|
||||
await until(() => /host: pid \d+/.test(shellLog()), "the shell never started its kernel", 60000);
|
||||
const log = shellLog();
|
||||
pids.push(Number(/shell: start .* pid=(\d+)/.exec(log)[1]), Number(/host: pid (\d+)/.exec(log)[1]));
|
||||
await end({ launcher, shellLog });
|
||||
await until(() => pids.every((pid) => !alive(pid)), `${name}: the shell or its kernel outlived the launch`, 20000);
|
||||
assert.doesNotMatch(shellLog(), /uncaught/i);
|
||||
console.log(` ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(` FAIL ${name}: ${err.message}\n${shellLog().split("\n").slice(-10).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
launcher.kill("SIGKILL");
|
||||
for (const pid of pids) if (alive(pid)) process.kill(pid, "SIGKILL");
|
||||
await sleep(500);
|
||||
rmSync(profile, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const handshaken = ({ shellLog }) => until(() => /host: handshake/.test(shellLog()), "no handshake", 60000);
|
||||
|
||||
await scenario("a launcher killed after the handshake takes the shell with it", {
|
||||
end: async (run) => {
|
||||
await handshaken(run);
|
||||
run.launcher.kill("SIGKILL");
|
||||
await until(() => /launcher gone/.test(run.shellLog()), "the shell never noticed its launcher was gone", 10000);
|
||||
},
|
||||
});
|
||||
|
||||
// A kernel that takes its time to handshake: the launch is still starting when
|
||||
// its launcher goes.
|
||||
if (process.platform !== "win32") {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "reasonix-slow-host-"));
|
||||
const slow = path.join(dir, "host.sh");
|
||||
writeFileSync(slow, `#!/bin/sh\nsleep 3\nexec ${JSON.stringify(host)} "$@"\n`, { mode: 0o755 });
|
||||
await scenario("a launcher killed before the handshake takes the starting shell with it", {
|
||||
kernel: slow,
|
||||
end: async (run) => {
|
||||
assert.doesNotMatch(run.shellLog(), /host: handshake/);
|
||||
run.launcher.kill("SIGKILL");
|
||||
},
|
||||
});
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await scenario("a shell whose console was closed still stops when asked", {
|
||||
end: async (run) => {
|
||||
await handshaken(run);
|
||||
run.launcher.stdout.destroy();
|
||||
run.launcher.stderr.destroy();
|
||||
// The kernel logs a request every few seconds, and each one is echoed.
|
||||
await sleep(8000);
|
||||
run.launcher.kill("SIGTERM");
|
||||
},
|
||||
});
|
||||
|
||||
await scenario("a launcher asked to stop stops the shell", {
|
||||
end: async (run) => {
|
||||
await handshaken(run);
|
||||
run.launcher.kill("SIGTERM");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
// With the project folder gone, a tool call is refused with the typed cause and the
|
||||
// window says so above the composer, with a way to choose the folder again.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { app } = require("electron");
|
||||
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
|
||||
|
||||
const { check, failures } = checker();
|
||||
|
||||
async function main() {
|
||||
let calls = 0;
|
||||
const { handler, requests } = scriptedModel(() => {
|
||||
calls++;
|
||||
if (calls === 1) return { name: "grep", arguments: { pattern: "hello" } };
|
||||
return null;
|
||||
});
|
||||
const model = await listen(handler);
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-live-"));
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-ws-"));
|
||||
process.env.REASONIX_HOME = home;
|
||||
seedHome(home, model.url, workspace);
|
||||
const { current } = require("../src/main.js");
|
||||
|
||||
try {
|
||||
const win = await settledWindow();
|
||||
const { client } = current();
|
||||
const runtimes = await until("a pane", async () => {
|
||||
const list = await client.json("GET", "/runtimes");
|
||||
return Array.isArray(list) && list.length ? list : null;
|
||||
});
|
||||
process.chdir(os.tmpdir());
|
||||
const gone = `${workspace}-moved`;
|
||||
fs.renameSync(workspace, gone);
|
||||
await client.request("POST", `${runtimes[0].base}/submit`, { input: "look for hello" });
|
||||
await until("the scripted turn", async () => requests.length >= 2, 60000);
|
||||
const result = String((requests[1].messages || []).filter((m) => m.role === "tool").at(-1)?.content || "");
|
||||
check("the model was told the typed cause, not a bare system error", result.startsWith("root missing: the project folder ") && result.includes("not a permission or argument problem"), result.slice(0, 200));
|
||||
const js = (src) => win.webContents.executeJavaScript(src);
|
||||
const banner = await until("the banner above the composer", async () => js(`(() => { const b = document.querySelector('[data-cause="root-missing"]'); return b ? b.textContent : null; })()`), 20000).catch(() => null);
|
||||
check("the window shows the banner", !!banner, banner);
|
||||
check("the banner offers to choose the folder again", await js(`!!document.querySelector('[data-action="root-missing.reselect"]')`));
|
||||
await wait(3000);
|
||||
process.stdout.write(` page: ${await js("document.body.innerText.slice(0, 300).replace(/\\n/g, ' | ')")}\n`);
|
||||
const png = path.join(os.tmpdir(), "rx-root-missing-live.png");
|
||||
try {
|
||||
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], png]);
|
||||
process.stdout.write(` screenshot: ${png}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(` (no screenshot: ${err.message})\n`);
|
||||
}
|
||||
fs.renameSync(gone, workspace);
|
||||
} catch (err) {
|
||||
check("the run completed", false, String(err && err.message ? err.message : err));
|
||||
}
|
||||
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
|
||||
app.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
app.whenReady().then(main);
|
||||
@@ -1573,6 +1573,155 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
|
||||
}
|
||||
});
|
||||
|
||||
test("a development shell leaves once the process that launched it is gone", () => {
|
||||
const { watchLauncher } = require("../src/launcher.js");
|
||||
mock.timers.enable({ apis: ["setInterval"] });
|
||||
try {
|
||||
// POSIX hands an orphan to another parent; Windows leaves the dead pid in place.
|
||||
for (const [why, end] of [["reparented", (s) => { s.ppid = 1; }], ["died in place", (s) => { s.alive = false; }]]) {
|
||||
const state = { ppid: 4242, alive: true };
|
||||
let lost = 0;
|
||||
watchLauncher({ packaged: false, env: {}, onLost: () => lost++, parent: () => state.ppid, alive: () => state.alive });
|
||||
mock.timers.tick(5000);
|
||||
assert.equal(lost, 0, `${why}: left while the launcher was still there`);
|
||||
end(state);
|
||||
mock.timers.tick(1000);
|
||||
assert.equal(lost, 1, `${why}: stayed after its launcher was gone`);
|
||||
mock.timers.tick(5000);
|
||||
assert.equal(lost, 1, `${why}: told more than once`);
|
||||
}
|
||||
|
||||
const state = { ppid: 4242 };
|
||||
let lost = 0;
|
||||
const watch = watchLauncher({ packaged: false, env: {}, onLost: () => lost++, parent: () => state.ppid, alive: () => true });
|
||||
watch.dispose();
|
||||
state.ppid = 1;
|
||||
mock.timers.tick(5000);
|
||||
assert.equal(lost, 0, "a disposed watch still acted");
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a packaged, handed-over or parentless shell belongs to no launcher", () => {
|
||||
const { watchLauncher, handOver } = require("../src/launcher.js");
|
||||
mock.timers.enable({ apis: ["setInterval"] });
|
||||
try {
|
||||
const handed = {};
|
||||
handOver(handed);
|
||||
for (const [why, options] of [
|
||||
["packaged", { packaged: true, env: {}, parent: () => 4242 }],
|
||||
["handed over by a relaunch", { packaged: false, env: handed, parent: () => 4242 }],
|
||||
["started by init", { packaged: false, env: {}, parent: () => 1 }],
|
||||
]) {
|
||||
let lost = 0;
|
||||
let ppid = options.parent();
|
||||
watchLauncher({ ...options, onLost: () => lost++, parent: () => ppid, alive: () => false });
|
||||
ppid = 7;
|
||||
mock.timers.tick(5000);
|
||||
assert.equal(lost, 0, why);
|
||||
}
|
||||
assert.deepEqual(handed, {}, "the hand-over mark reached this shell's own children");
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a relaunch hands the shell over before it asks for the restart", () => {
|
||||
const { actHandler } = require("../src/acts.js");
|
||||
const { relaunchForOzonePlatform } = require("../src/ozone.js");
|
||||
const key = "REASONIX_STUDIO_HANDED_OVER";
|
||||
const saved = process.env[key];
|
||||
delete process.env[key];
|
||||
try {
|
||||
const seen = [];
|
||||
actHandler({ app: { relaunch: () => seen.push(process.env[key]) }, showWindow: () => {} })("relaunch");
|
||||
assert.deepEqual(seen, ["1"]);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env[key];
|
||||
else process.env[key] = saved;
|
||||
}
|
||||
const env = { XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" };
|
||||
const seen = [];
|
||||
relaunchForOzonePlatform({ relaunch: () => seen.push(env[key]), exit: () => {} }, { platform: "linux", env, argv: ["x"] });
|
||||
assert.deepEqual(seen, ["1"]);
|
||||
});
|
||||
|
||||
test("output for a console the launcher closed is dropped, never thrown", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { consoleEcho } = require("../src/launcher.js");
|
||||
const stream = Object.assign(new EventEmitter(), { written: [], full: false, broken: false });
|
||||
stream.write = (chunk) => {
|
||||
if (stream.broken) throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
stream.written.push(chunk);
|
||||
return !stream.full;
|
||||
};
|
||||
const echo = consoleEcho(stream);
|
||||
echo("a");
|
||||
stream.full = true;
|
||||
echo("b");
|
||||
echo("c");
|
||||
assert.deepEqual(stream.written, ["a", "b"], "a backed-up console kept queueing");
|
||||
stream.emit("drain");
|
||||
stream.full = false;
|
||||
echo("d");
|
||||
stream.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
echo("e");
|
||||
assert.deepEqual(stream.written, ["a", "b", "d"], "a failed console was written to again");
|
||||
|
||||
const sync = Object.assign(new EventEmitter(), { broken: true, calls: 0 });
|
||||
sync.write = () => {
|
||||
sync.calls++;
|
||||
throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
};
|
||||
const echoSync = consoleEcho(sync);
|
||||
assert.doesNotThrow(() => echoSync("x"));
|
||||
echoSync("y");
|
||||
assert.equal(sync.calls, 1);
|
||||
});
|
||||
|
||||
test("a shell whose console pipe was closed keeps running", async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const script = `
|
||||
const { consoleEcho } = require(${JSON.stringify(require.resolve("../src/launcher.js"))});
|
||||
const echo = consoleEcho(process.stderr);
|
||||
process.stdin.once("data", () => {
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
echo("x".repeat(512) + "\\n");
|
||||
if (++n === 50) { clearInterval(timer); process.stdout.write("survived\\n"); }
|
||||
}, 2);
|
||||
});`;
|
||||
const child = spawn(process.execPath, ["-e", script], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.destroy();
|
||||
let out = "";
|
||||
child.stdout.on("data", (chunk) => { out += chunk; });
|
||||
child.stdin.end("go\n");
|
||||
const code = await new Promise((resolve) => child.on("exit", resolve));
|
||||
assert.equal(code, 0);
|
||||
assert.equal(out, "survived\n");
|
||||
});
|
||||
|
||||
test("the launch credential cookie is put back when something removes it, and only then", async () => {
|
||||
const { keepCredential } = require("../src/credential.js");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const jar = new EventEmitter();
|
||||
const sets = [];
|
||||
const ses = { cookies: Object.assign(jar, { set: async (c) => void sets.push(c) }) };
|
||||
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
|
||||
const lines = [];
|
||||
keepCredential(ses, cookie, (l) => lines.push(l));
|
||||
const gone = { name: "reasonix_token", domain: "127.0.0.1" };
|
||||
jar.emit("changed", {}, { ...gone, name: "other" }, "explicit", true);
|
||||
jar.emit("changed", {}, { ...gone, domain: "example.com" }, "explicit", true);
|
||||
jar.emit("changed", {}, gone, "overwrite", true);
|
||||
jar.emit("changed", {}, gone, "explicit", false);
|
||||
assert.equal(sets.length, 0, "an unrelated change must not touch the jar");
|
||||
jar.emit("changed", {}, gone, "evicted", true);
|
||||
assert.deepEqual(sets, [cookie]);
|
||||
assert.match(lines[0], /evicted/);
|
||||
});
|
||||
|
||||
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
|
||||
const { trayAsset, SCALES } = require("../src/trayimage.js");
|
||||
const dim = (f) => {
|
||||
@@ -1590,6 +1739,25 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
|
||||
test("the macOS tray is a black-on-transparent Template pair, not the colour badge", () => {
|
||||
const { trayImage } = require("../src/trayimage.js");
|
||||
const asked = [];
|
||||
const nativeImage = { createFromPath: (f) => (asked.push(f), { isEmpty: () => false }) };
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
trayImage(nativeImage, 2);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
}
|
||||
assert.match(path.basename(asked[0]), /^trayTemplate\.png$/);
|
||||
for (const [file, px] of [["trayTemplate.png", 18], ["[email protected]", 36]]) {
|
||||
const b = fs.readFileSync(path.join(path.dirname(asked[0]), file));
|
||||
assert.deepEqual([b.readUInt32BE(16), b.readUInt32BE(20)], [px, px], file);
|
||||
assert.equal(b[25], 6, `${file} must carry an alpha channel`);
|
||||
}
|
||||
});
|
||||
|
||||
function pe(machine) {
|
||||
const b = Buffer.alloc(0x100);
|
||||
b.writeUInt16LE(0x5a4d, 0);
|
||||
@@ -2281,6 +2449,115 @@ test("a parked page is attached again when it is shown, and a page already on sc
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("a page laid out wider than the pane is scaled to fit it, and keeps the viewport the agent asked for", async () => {
|
||||
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 win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView() {}, removeChildView() {} },
|
||||
};
|
||||
const sent = [];
|
||||
const view = {
|
||||
setBounds() {},
|
||||
webContents: { isDestroyed: () => false, debugger: { sendCommand: async (method, params) => sent.push([method, params]) } },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const entry = { view, at: null, attached: true };
|
||||
views.entries.set("t1", entry);
|
||||
views.keepKeyboard = () => {};
|
||||
entry.override = { width: 1440, height: 1000, deviceScaleFactor: 0, mobile: false };
|
||||
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][1].scale, 0.5, "a 1440 wide layout in a 720 wide pane is halved");
|
||||
assert.equal(sent[0][1].width, 1440, "the layout viewport is still the one the agent asked for");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
|
||||
assert.equal(sent.length, 1, "the same pane sends nothing again");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 360, height: 800 });
|
||||
assert.equal(sent.at(-1)[1].scale, 0.25, "a narrower pane scales further");
|
||||
|
||||
views.show("t1", { x: 0, y: 0, width: 2000, height: 1200 });
|
||||
assert.equal(sent.at(-1)[1].scale, 1, "a pane larger than the layout never enlarges it");
|
||||
|
||||
const settled = sent.length;
|
||||
views.fit(entry);
|
||||
assert.equal(sent.length, settled, "an unchanged scale is not sent again");
|
||||
views.fit(entry, true);
|
||||
assert.equal(sent.length, settled + 1, "a capture that dropped the scale gets it back");
|
||||
assert.equal(sent.at(-1)[1].scale, 1);
|
||||
entry.override = undefined;
|
||||
const before = sent.length;
|
||||
views.show("t1", { x: 0, y: 0, width: 500, height: 800 });
|
||||
assert.equal(sent.length, before, "with no override there is nothing to fit");
|
||||
});
|
||||
|
||||
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
|
||||
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 placement;
|
||||
try {
|
||||
({ placement } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const panel = { x: 380, y: 60, width: 560, height: 620 };
|
||||
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
|
||||
for (const none of [{ width: 0, height: 0 }, { width: 0, height: 600 }, { width: 390, height: 0 }, { width: NaN, height: 600 }, { width: 390, height: Infinity }, { width: -1, height: 600 }]) {
|
||||
assert.deepEqual(placement(panel, none), { at: panel, scale: 1 }, `${JSON.stringify(none)} is no viewport at all`);
|
||||
}
|
||||
const wide = placement(panel, { width: 1280, height: 900 });
|
||||
assert.equal(wide.scale, 0.4375);
|
||||
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
|
||||
const phone = placement(panel, { width: 390, height: 844 });
|
||||
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
|
||||
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
|
||||
});
|
||||
|
||||
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
|
||||
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 scaleInput;
|
||||
try {
|
||||
({ scaleInput } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
|
||||
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
|
||||
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
|
||||
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
|
||||
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
|
||||
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
|
||||
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
|
||||
}
|
||||
const key = { type: "keyDown", key: "a" };
|
||||
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
|
||||
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -2368,6 +2645,109 @@ test("CUA embedded control stops the old service before admitting a fresh one",
|
||||
assert.equal(fs.existsSync(owned.abortSocket), false);
|
||||
});
|
||||
|
||||
test("CUA waits for socket permissions on initial launch and abort restart", { skip: process.platform === "win32" }, async (t) => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const net = require("node:net");
|
||||
const promises = require("node:fs/promises");
|
||||
const { startEmbedded, metadata, stopProcess } = require("../src/cua");
|
||||
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
|
||||
const children = [];
|
||||
const observed = new Set();
|
||||
const ready = new Set();
|
||||
let owned;
|
||||
t.after(async () => {
|
||||
await owned?.stop();
|
||||
for (const child of children) await stopProcess(child);
|
||||
});
|
||||
const lstat = promises.lstat;
|
||||
t.mock.method(promises, "lstat", async (...args) => {
|
||||
const current = await lstat(...args);
|
||||
if (path.basename(args[0]) === "driver.sock" && (current.mode & 0o777) !== 0o600) {
|
||||
const child = children.at(-1);
|
||||
if (!observed.has(child.pid)) {
|
||||
observed.add(child.pid);
|
||||
child.send("secure");
|
||||
}
|
||||
} else if (path.basename(args[0]) === "driver.sock") {
|
||||
ready.add(children.at(-1).pid);
|
||||
}
|
||||
return current;
|
||||
});
|
||||
const connect = net.createConnection;
|
||||
t.mock.method(net, "createConnection", (...args) => {
|
||||
if (path.basename(args[0]) === "driver.sock") assert.ok(ready.has(children.at(-1).pid), "metadata was requested before private permissions were observed");
|
||||
return connect(...args);
|
||||
});
|
||||
owned = await startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
|
||||
const child = spawn(process.execPath, [fake, ...args, "--defer-permissions"], { ...options, stdio: [...options.stdio, "ipc"] });
|
||||
children.push(child);
|
||||
return child;
|
||||
});
|
||||
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.deepEqual([...observed], [before.result.pid, after.result.pid]);
|
||||
assert.equal((await lstat(owned.socket)).mode & 0o777, 0o600);
|
||||
assert.throws(() => process.kill(before.result.pid, 0));
|
||||
});
|
||||
|
||||
test("CUA permission readiness remains bounded without connecting to a non-private socket", { skip: process.platform === "win32" }, async (t) => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const promises = require("node:fs/promises");
|
||||
const { startEmbedded, stopProcess } = require("../src/cua");
|
||||
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
|
||||
let child;
|
||||
t.after(async () => { if (child) await stopProcess(child); });
|
||||
const now = Date.now();
|
||||
let expired = false;
|
||||
t.mock.method(Date, "now", () => now + (expired ? 10001 : 0));
|
||||
const lstat = promises.lstat;
|
||||
t.mock.method(promises, "lstat", async (...args) => {
|
||||
const current = await lstat(...args);
|
||||
if (path.basename(args[0]) === "driver.sock" && (current.mode & 0o777) !== 0o600) expired = true;
|
||||
return current;
|
||||
});
|
||||
await assert.rejects(startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
|
||||
child = spawn(process.execPath, [fake, ...args, "--defer-permissions"], options);
|
||||
return child;
|
||||
}), /Embedded CUA failed readiness/);
|
||||
assert.equal(expired, true);
|
||||
assert.equal(child.exitCode, 0);
|
||||
});
|
||||
|
||||
for (const kind of ["file", "symlink"]) {
|
||||
test(`CUA readiness rejects a ${kind} instead of waiting for permissions`, { skip: process.platform === "win32" }, async (t) => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const { startEmbedded, stopProcess } = require("../src/cua");
|
||||
let child, socket;
|
||||
t.after(async () => {
|
||||
if (child) await stopProcess(child);
|
||||
if (socket) fs.rmSync(path.dirname(socket), { recursive: true, force: true });
|
||||
});
|
||||
const script = `
|
||||
const fs = require("node:fs");
|
||||
const socket = process.argv[1];
|
||||
if (process.argv[2] === "symlink") fs.symlinkSync("missing-target", socket);
|
||||
else fs.writeFileSync(socket, "", { mode: 0o600 });
|
||||
process.stdin.resume();
|
||||
process.stdin.on("end", () => process.exit(0));`;
|
||||
await assert.rejects(startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
|
||||
socket = args[args.indexOf("--socket") + 1];
|
||||
child = spawn(process.execPath, ["-e", script, socket, kind], options);
|
||||
return child;
|
||||
}), /Invalid private CUA socket/);
|
||||
assert.equal(child.exitCode, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("CUA repeated quit waits for owned driver cleanup", async () => {
|
||||
const { createQuitGate } = require("../src/cua");
|
||||
@@ -2759,3 +3139,39 @@ test("a window that fails to raise is given its opacity and mouse back", async (
|
||||
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
|
||||
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
|
||||
});
|
||||
|
||||
const { processMemoryRows, processMemoryHandler } = require("../src/procmem.js");
|
||||
|
||||
test("the shell's process memory is only a fixed type and two whole numbers per process", () => {
|
||||
const metrics = [
|
||||
{ pid: 4740, type: "Tab", name: "C:\\Users\\someone\\x", cmd: "--user-data-dir=/home/someone", memory: { workingSetSize: 3992908, privateBytes: 3900000, peakWorkingSetSize: 9 } },
|
||||
{ pid: 11, type: "Browser", memory: { workingSetSize: 120000, privateBytes: 90000 } },
|
||||
{ pid: 12, type: "GPU", memory: { workingSetSize: 76000 } },
|
||||
{ pid: 13, type: "Utility", memory: { workingSetSize: 6000, privateBytes: 5000 } },
|
||||
{ pid: 14, type: "Zygote", memory: { workingSetSize: 1000, privateBytes: 900 } },
|
||||
{ pid: 15, type: "Tab", memory: { workingSetSize: Number.NaN, privateBytes: -4 } },
|
||||
];
|
||||
const rows = processMemoryRows(metrics);
|
||||
assert.deepEqual(rows, [
|
||||
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
|
||||
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
|
||||
{ process: "gpu", workingSetKB: 76000, privateKB: 0 },
|
||||
{ process: "utility", workingSetKB: 6000, privateKB: 5000 },
|
||||
{ process: "utility", workingSetKB: 1000, privateKB: 900 },
|
||||
{ process: "renderer", workingSetKB: 0, privateKB: 0 },
|
||||
]);
|
||||
assert.ok(rows.every((r) => Object.keys(r).sort().join() === "privateKB,process,workingSetKB"));
|
||||
assert.doesNotMatch(JSON.stringify(rows), /Users|pid|user-data|cmd/i);
|
||||
});
|
||||
|
||||
test("the shell reports at most eleven processes, since the kernel adds its own row to a dozen", () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => ({ pid: i, type: "Utility", memory: { workingSetSize: 1, privateBytes: 1 } }));
|
||||
assert.equal(processMemoryRows(many).length, 11);
|
||||
});
|
||||
|
||||
test("only the app's own window is answered, and nothing is read from the asker", () => {
|
||||
const app = { getAppMetrics: () => [{ type: "Tab", memory: { workingSetSize: 5, privateBytes: 4 } }] };
|
||||
assert.deepEqual(processMemoryHandler(app, () => true)({}), [{ process: "renderer", workingSetKB: 5, privateKB: 4 }]);
|
||||
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
|
||||
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
|
||||
});
|
||||
@@ -39,8 +39,8 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.19.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-map-gl": "8.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.10.1",
|
||||
@@ -56,14 +56,14 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.2",
|
||||
"playwright": "^1.64.0",
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1719 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user