Compare commits
2145 Commits
feat/slash
...
fix/sideba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ba61028f | ||
|
|
efe5c62ae1 | ||
|
|
f938f6617b | ||
|
|
e882f7d207 | ||
|
|
e38fd8603f | ||
|
|
89283aa788 | ||
|
|
f7dc27f2d0 | ||
|
|
ed560e466f | ||
|
|
b5f1dc9d95 | ||
|
|
f58ad7625f | ||
|
|
49c6d8019f | ||
|
|
86db180a17 | ||
|
|
31e59cd583 | ||
|
|
d2bfcd70e7 | ||
|
|
12d22e1c89 | ||
|
|
75cb78a5b1 | ||
|
|
791b568f78 | ||
|
|
d46642319b | ||
|
|
a96d37ca69 | ||
|
|
05b0b82937 | ||
|
|
908d9331af | ||
|
|
29f0463f65 | ||
|
|
66f353fe7a | ||
|
|
511a0c22b7 | ||
|
|
da3f2b4898 | ||
|
|
438e782f81 | ||
|
|
d354030974 | ||
|
|
ef777d6bb6 | ||
|
|
b9c35d9fdc | ||
|
|
69f645c662 | ||
|
|
efec5fc751 | ||
|
|
bf4544784a | ||
|
|
c9a7c77b24 | ||
|
|
aeb6b2ffad | ||
|
|
07ce1d73ff | ||
|
|
1113f17d4c | ||
|
|
8252ae2da1 | ||
|
|
d82ecaf9dc | ||
|
|
521ea4ae5b | ||
|
|
05e7e06146 | ||
|
|
cb8c8fee9a | ||
|
|
ed05152cb1 | ||
|
|
a8054d1e83 | ||
|
|
2e0a835e07 | ||
|
|
da26954dd0 | ||
|
|
892197c43e | ||
|
|
02bd6e4a24 | ||
|
|
99d4820b39 | ||
|
|
022aa10063 | ||
|
|
ae0741a346 | ||
|
|
4ee70be690 | ||
|
|
fdbaae6a33 | ||
|
|
7d0a0ae3ba | ||
|
|
242add587f | ||
|
|
6fba598eaf | ||
|
|
75a54f0259 | ||
|
|
c63144ab14 | ||
|
|
f07c39b265 | ||
|
|
40181afded | ||
|
|
2f1b9efe9a | ||
|
|
ff30cef8a4 | ||
|
|
3d958d5466 | ||
|
|
cad7ed1cb8 | ||
|
|
8195497cec | ||
|
|
1af227b619 | ||
|
|
b77e730657 | ||
|
|
37e5f077b8 | ||
|
|
0eb7e1864c | ||
|
|
0d336272f9 | ||
|
|
ace6a42ea6 | ||
|
|
6d2a1ce217 | ||
|
|
c9d73469c3 | ||
|
|
29353e2e81 | ||
|
|
fdc50a0feb | ||
|
|
a1413a011e | ||
|
|
bfbeea0f20 | ||
|
|
2c85b1b409 | ||
|
|
8b7b7e154f | ||
|
|
bb9bddebb4 | ||
|
|
6e570561b6 | ||
|
|
fb6363ae58 | ||
|
|
1b77e086d4 | ||
|
|
d371a4c8c3 | ||
|
|
03e8b7c4ba | ||
|
|
8aadcaa1bd | ||
|
|
96800c27ec | ||
|
|
13d1712850 | ||
|
|
c5546f0d5b | ||
|
|
3de5ea818d | ||
|
|
dc07f1e021 | ||
|
|
310a248a44 | ||
|
|
88e7684258 | ||
|
|
716f901504 | ||
|
|
e817c0cee5 | ||
|
|
e634791585 | ||
|
|
78071f8ec4 | ||
|
|
c48751a99c | ||
|
|
86e0916fa3 | ||
|
|
dc89bc4004 | ||
|
|
0c7e649676 | ||
|
|
45ce07a098 | ||
|
|
aed8dc1ade | ||
|
|
86a341be62 | ||
|
|
ff78e9a564 | ||
|
|
60a60779d7 | ||
|
|
32da00cb2f | ||
|
|
0420f2804c | ||
|
|
4de660bec6 | ||
|
|
58f638463f | ||
|
|
f1afc722da | ||
|
|
bc75d58e9e | ||
|
|
2efd265697 | ||
|
|
9c1f1476bc | ||
|
|
e8352c8d21 | ||
|
|
551685351f | ||
|
|
3fbbac07fe | ||
|
|
03bec49299 | ||
|
|
6779ba2367 | ||
|
|
8598e906ef | ||
|
|
300fc486a4 | ||
|
|
f014b46b56 | ||
|
|
833f5acda1 | ||
|
|
d03c404cb4 | ||
|
|
68ea6e521b | ||
|
|
4912e85ac8 | ||
|
|
39d8ff59aa | ||
|
|
070944f64f | ||
|
|
d4db45e8a9 | ||
|
|
451792d326 | ||
|
|
c7ca312f97 | ||
|
|
1e6e58b23b | ||
|
|
e98e71401a | ||
|
|
bec1d0d3d4 | ||
|
|
9f6ea67415 | ||
|
|
bd7443b39b | ||
|
|
93bef830ce | ||
|
|
2dfbd1c1f6 | ||
|
|
1d9f230be4 | ||
|
|
9bf295da48 | ||
|
|
eebd750781 | ||
|
|
aa11300175 | ||
|
|
7b40d1b261 | ||
|
|
2c10c601a8 | ||
|
|
578ac9f1a9 | ||
|
|
2accb47e4d | ||
|
|
b65916e0d1 | ||
|
|
9207840db4 | ||
|
|
784468d6c3 | ||
|
|
02b5f403db | ||
|
|
5d0d9e6323 | ||
|
|
64be2b2cd1 | ||
|
|
dd2400fb2a | ||
|
|
5d001cb953 | ||
|
|
d23c4a3f10 | ||
|
|
e750ad5e75 | ||
|
|
246ee490f6 | ||
|
|
d62a20fba9 | ||
|
|
7f68bf79b6 | ||
|
|
34bb7250f8 | ||
|
|
34696dc8b9 | ||
|
|
9a9afb389a | ||
|
|
1e9ae7649d | ||
|
|
5cb9026541 | ||
|
|
81e78dced5 | ||
|
|
565944ec71 | ||
|
|
ec2c69c230 | ||
|
|
f1deffa681 | ||
|
|
4b19066cc1 | ||
|
|
ea79b26b79 | ||
|
|
6eb355954c | ||
|
|
91ca52d3c5 | ||
|
|
0149d2b678 | ||
|
|
ecfddb7807 | ||
|
|
35228ecae9 | ||
|
|
cfcc4548bb | ||
|
|
21a9b3b66f | ||
|
|
837749dced | ||
|
|
59a8eecd7e | ||
|
|
542cf011a0 | ||
|
|
4355d9acca | ||
|
|
712bc74c30 | ||
|
|
0396b678fa | ||
|
|
eaf1b6bfee | ||
|
|
06cb2bf58d | ||
|
|
8fdb3b38eb | ||
|
|
5689d7fb98 | ||
|
|
2424404fb4 | ||
|
|
17a09cc721 | ||
|
|
bc4d8ce398 | ||
|
|
279f799388 | ||
|
|
1d658109a8 | ||
|
|
5a446f3a21 | ||
|
|
52b6bf04af | ||
|
|
76a42da676 | ||
|
|
51a9053387 | ||
|
|
db0235a26a | ||
|
|
fac21e6eb4 | ||
|
|
e872f5335b | ||
|
|
a23e272877 | ||
|
|
870bfa94ed | ||
|
|
d297e17958 | ||
|
|
6a25e23909 | ||
|
|
dc66527114 | ||
|
|
110b5dafee | ||
|
|
5fd699d0bf | ||
|
|
c1e50b7184 | ||
|
|
c7e0dc10fc | ||
|
|
01579aa7d7 | ||
|
|
42cd8a02bb | ||
|
|
96f1846c2c | ||
|
|
7c336588ea | ||
|
|
814e9a500e | ||
|
|
370896e994 | ||
|
|
573354f5e4 | ||
|
|
c721947346 | ||
|
|
56339a17cc | ||
|
|
567d8e5aa4 | ||
|
|
da3a141c58 | ||
|
|
c0c8ee217f | ||
|
|
411ce7e231 | ||
|
|
b709898fb3 | ||
|
|
826013c990 | ||
|
|
482fcd2f2c | ||
|
|
6c7f224ce1 | ||
|
|
db146837a1 | ||
|
|
1ef2de1276 | ||
|
|
60cbf97079 | ||
|
|
13a62d1a6f | ||
|
|
534f28a78f | ||
|
|
3993c9a3b4 | ||
|
|
f552820a75 | ||
|
|
784ea4f7d5 | ||
|
|
f07a58965e | ||
|
|
773dad256e | ||
|
|
ffca65d15f | ||
|
|
654b6a943b | ||
|
|
768d5ccafe | ||
|
|
8b3cb373d4 | ||
|
|
9d09a7879c | ||
|
|
495a39b5a9 | ||
|
|
ba824a4b2d | ||
|
|
8b6b97c3f6 | ||
|
|
47e440f73a | ||
|
|
80c1edc3ff | ||
|
|
1d55dc0fe3 | ||
|
|
57e81d3c24 | ||
|
|
cd6bacae23 | ||
|
|
447db67b18 | ||
|
|
019726f2d1 | ||
|
|
3be7ac8524 | ||
|
|
058f00ba0b | ||
|
|
fb85cb3271 | ||
|
|
d47db55106 | ||
|
|
5045a9a00d | ||
|
|
36a2584ac7 | ||
|
|
cadaf2c835 | ||
|
|
72455b902f | ||
|
|
e389bd478b | ||
|
|
0873351401 | ||
|
|
ced9efd964 | ||
|
|
6822d509d7 | ||
|
|
9f588d91f4 | ||
|
|
486af3f453 | ||
|
|
7a283f86a8 | ||
|
|
917bcb714e | ||
|
|
646ea6ef0b | ||
|
|
3d8a759eba | ||
|
|
4c8806ad38 | ||
|
|
0824bc0236 | ||
|
|
0e17e55be9 | ||
|
|
54e0fc342e | ||
|
|
cc8506ae79 | ||
|
|
f2606a17ba | ||
|
|
1a4fade2f7 | ||
|
|
e344b7df9c | ||
|
|
256fdcb3cf | ||
|
|
acdfbee4f9 | ||
|
|
ff69a9bd9c | ||
|
|
91278d8b4e | ||
|
|
b748b86b23 | ||
|
|
1a8b106f34 | ||
|
|
87baca82db | ||
|
|
388d302472 | ||
|
|
e0c19607b7 | ||
|
|
fe77d3eb56 | ||
|
|
230211fe26 | ||
|
|
0f4e0cbe5f | ||
|
|
40b7447a80 | ||
|
|
d30e9b7d56 | ||
|
|
bc8e5ad6b3 | ||
|
|
4b3e9c0f33 | ||
|
|
d83ea7f2da | ||
|
|
7004616e03 | ||
|
|
0d37a92c16 | ||
|
|
37cbe387bf | ||
|
|
8544df36b8 | ||
|
|
3125637ad6 | ||
|
|
aadb66e956 | ||
|
|
ad6d048934 | ||
|
|
d19a0249f8 | ||
|
|
b91e72824f | ||
|
|
b573231cd1 | ||
|
|
862f34ade7 | ||
|
|
d8ad865cf5 | ||
|
|
8a20f44228 | ||
|
|
a056042caa | ||
|
|
d430a3a5c7 | ||
|
|
319b4d02a0 | ||
|
|
30ca87094d | ||
|
|
98ab2b4eae | ||
|
|
b63175d822 | ||
|
|
6539c09a93 | ||
|
|
23ea4a21e0 | ||
|
|
34686027b1 | ||
|
|
7b7c107ffe | ||
|
|
36cfe75a0b | ||
|
|
d425f1ebea | ||
|
|
8580b85f0b | ||
|
|
5ff4ac7fb7 | ||
|
|
a2981c5a2c | ||
|
|
a59ac5cf6f | ||
|
|
5567bceb66 | ||
|
|
f98d31cdd3 | ||
|
|
e0896de2bf | ||
|
|
8d73c16488 | ||
|
|
0f8d0f37fd | ||
|
|
d912b02a43 | ||
|
|
4dca662a5d | ||
|
|
9063b9e61d | ||
|
|
50049fd220 | ||
|
|
9ead312118 | ||
|
|
f02960df26 | ||
|
|
b60db040e2 | ||
|
|
af42cb3ded | ||
|
|
13dab38a26 | ||
|
|
351c73be01 | ||
|
|
55ead9636c | ||
|
|
fd597a796b | ||
|
|
ff3d8cab2b | ||
|
|
9ae03b92bb | ||
|
|
5424b4173c | ||
|
|
30a8478e1a | ||
|
|
2fc926ab1c | ||
|
|
1ac1e72a47 | ||
|
|
9450873c1b | ||
|
|
f40f16608c | ||
|
|
5fb6a0fd32 | ||
|
|
3b2aff0d6f | ||
|
|
a2bea8e366 | ||
|
|
2d583e877b | ||
|
|
0c55b1e9ce | ||
|
|
51cd9c7ff4 | ||
|
|
7edc464b82 | ||
|
|
754481716e | ||
|
|
0c3d46cb72 | ||
|
|
654f9e5053 | ||
|
|
17fad54ca0 | ||
|
|
0f7f7bb95f | ||
|
|
ffbf75d740 | ||
|
|
4642fae193 | ||
|
|
5fe8c4ab8c | ||
|
|
7b8405cbfb | ||
|
|
a96e7f59c0 | ||
|
|
57f3d209de | ||
|
|
40757a8c18 | ||
|
|
472b8fe15d | ||
|
|
721737cc77 | ||
|
|
464de2978b | ||
|
|
9d22646120 | ||
|
|
f1aa260b0e | ||
|
|
b5c307d07f | ||
|
|
2e1514095d | ||
|
|
f4b3f33c8e | ||
|
|
2d1d793651 | ||
|
|
2f47b3f6bd | ||
|
|
302bb64457 | ||
|
|
de898c423b | ||
|
|
47ebe29195 | ||
|
|
cc74e0d188 | ||
|
|
d7d98c3971 | ||
|
|
5bf7a9d0db | ||
|
|
3ad0d2fe23 | ||
|
|
da98528651 | ||
|
|
75dd1781b7 | ||
|
|
1b947dcdf9 | ||
|
|
39073d5196 | ||
|
|
7725dd6795 | ||
|
|
db61451c67 | ||
|
|
9780748bbb | ||
|
|
f5cec1dd8b | ||
|
|
758f30eb7d | ||
|
|
7e1a17e5e6 | ||
|
|
4997a5b93f | ||
|
|
1092b30531 | ||
|
|
0704fe7dbb | ||
|
|
7d93de710e | ||
|
|
d51eca64cc | ||
|
|
d0f9e22a4b | ||
|
|
39b375e32b | ||
|
|
3b6ec501aa | ||
|
|
2b254a9b39 | ||
|
|
429a2d7849 | ||
|
|
1cce83b21e | ||
|
|
8255e4649c | ||
|
|
7eef176afc | ||
|
|
06e496540f | ||
|
|
f76e3c1419 | ||
|
|
bf6df6d6b7 | ||
|
|
b4776af38c | ||
|
|
cd65e8e755 | ||
|
|
28e547f120 | ||
|
|
05a254746e | ||
|
|
529372f762 | ||
|
|
3b18efdd25 | ||
|
|
6e044b5f2f | ||
|
|
310f916675 | ||
|
|
acd40e1780 | ||
|
|
b5fd66c92d | ||
|
|
45c1ccdfcf | ||
|
|
76600e80ba | ||
|
|
483a50f107 | ||
|
|
31943dcecb | ||
|
|
717fb9e413 | ||
|
|
ad7ef27f66 | ||
|
|
0d3b8f6ac3 | ||
|
|
6492e90c1b | ||
|
|
e4b3c8b98d | ||
|
|
8b8e078ef8 | ||
|
|
44a3539ffa | ||
|
|
0daaa5b592 | ||
|
|
6866cca6d7 | ||
|
|
c145a0d116 | ||
|
|
6c0a01dc90 | ||
|
|
41c9c214fc | ||
|
|
41d56c06b9 | ||
|
|
9f999f6554 | ||
|
|
9f59ff325b | ||
|
|
c415ccaed5 | ||
|
|
403904ecd1 | ||
|
|
32550154f9 | ||
|
|
6996c0f330 | ||
|
|
cf4f1ed03a | ||
|
|
c913f05fb5 | ||
|
|
88d76d4be5 | ||
|
|
b52ab96e2c | ||
|
|
f0a8b34198 | ||
|
|
64d29b0c31 | ||
|
|
9b47f463b7 | ||
|
|
9605ad76c5 | ||
|
|
c129f0bbaa | ||
|
|
9e22f019db | ||
|
|
6f58d508b8 | ||
|
|
84eadd92a1 | ||
|
|
fd918bf6bf | ||
|
|
4e1806947d | ||
|
|
8aca606a6f | ||
|
|
56799a21be | ||
|
|
d2a0e416ea | ||
|
|
43afad9f51 | ||
|
|
5d73a412c6 | ||
|
|
d0e8faea97 | ||
|
|
cd25d69b4d | ||
|
|
c3adc50cb2 | ||
|
|
cbb9872478 | ||
|
|
39e24c9937 | ||
|
|
fa1bc589e4 | ||
|
|
0e003cb7f1 | ||
|
|
a90fe1b245 | ||
|
|
fb164b321e | ||
|
|
884211a924 | ||
|
|
9bd6b3fd54 | ||
|
|
dc06b225cd | ||
|
|
cdb35c3aae | ||
|
|
4e4f5558fc | ||
|
|
8479dc97da | ||
|
|
86ddd3c69c | ||
|
|
49d53ff0bb | ||
|
|
97e8f9d619 | ||
|
|
5392fa0dfa | ||
|
|
63d017c3af | ||
|
|
40646c73af | ||
|
|
43ea7665ef | ||
|
|
ba131b0164 | ||
|
|
0693c7804f | ||
|
|
6c69ea2c91 | ||
|
|
1e10dc1d3b | ||
|
|
c22a37976d | ||
|
|
9b9bbae501 | ||
|
|
7bfc32fe33 | ||
|
|
b073deee20 | ||
|
|
89c5035aa2 | ||
|
|
cb7791c8a4 | ||
|
|
9a14267dfa | ||
|
|
010d305401 | ||
|
|
3210c91f6b | ||
|
|
e3cea55d72 | ||
|
|
687a902f3e | ||
|
|
fe860de148 | ||
|
|
bc8a59faa4 | ||
|
|
91bcdad503 | ||
|
|
ab97c6880b | ||
|
|
65dd73b4c3 | ||
|
|
b69aa011fe | ||
|
|
e3a44b10bc | ||
|
|
5b8007784b | ||
|
|
0d6e78b718 | ||
|
|
46ab4cb19e | ||
|
|
32edaad823 | ||
|
|
5dcd48544a | ||
|
|
1e05925e47 | ||
|
|
fb47f1cbeb | ||
|
|
15d1421cf2 | ||
|
|
899bbd40d7 | ||
|
|
555b2578a8 | ||
|
|
0229b8bbd8 | ||
|
|
552f9eff7b | ||
|
|
36e0cffaaf | ||
|
|
e17a9c6abf | ||
|
|
6180603ef4 | ||
|
|
810374d648 | ||
|
|
968b967854 | ||
|
|
110079d99d | ||
|
|
34a126a6d7 | ||
|
|
31462f64d8 | ||
|
|
de0a488985 | ||
|
|
15f16de651 | ||
|
|
b46855d8c4 | ||
|
|
feaad8250b | ||
|
|
fa7df1976d | ||
|
|
2cd62f94a5 | ||
|
|
a74c19feed | ||
|
|
1ad4a7194e | ||
|
|
beec504ebd | ||
|
|
fe1133e2c5 | ||
|
|
6f37f1d8ff | ||
|
|
57700f33a9 | ||
|
|
2700794228 | ||
|
|
416894c642 | ||
|
|
db88378ae3 | ||
|
|
e97b4973bb | ||
|
|
832dfb02fe | ||
|
|
15e3a2a395 | ||
|
|
8c472c210f | ||
|
|
833bbcd166 | ||
|
|
d7440baef6 | ||
|
|
58b131919f | ||
|
|
186e86660a | ||
|
|
18d47b47d2 | ||
|
|
eb1e2c7a3b | ||
|
|
6ea4cb0012 | ||
|
|
184f5a5fc3 | ||
|
|
4ad359ffcd | ||
|
|
38cc2a3288 | ||
|
|
28c49db494 | ||
|
|
026e6c4df4 | ||
|
|
841dfc693e | ||
|
|
f38278d919 | ||
|
|
9545edcb49 | ||
|
|
f3554a3ad8 | ||
|
|
b30359e9cd | ||
|
|
d3898ee8df | ||
|
|
d1c2fc4bc8 | ||
|
|
a5a3ab958f | ||
|
|
165861e78d | ||
|
|
e7c355ee85 | ||
|
|
052a58f2f7 | ||
|
|
5ff56ffb4e | ||
|
|
9a3dd626a1 | ||
|
|
aae4b2952f | ||
|
|
aec622fe63 | ||
|
|
e6287270d9 | ||
|
|
c05a7b5390 | ||
|
|
020fecef5c | ||
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
51dfd6efdb | ||
|
|
4fad74738a | ||
|
|
69f0469530 | ||
|
|
eb1ee36f59 | ||
|
|
b341512564 | ||
|
|
6734f2d71c | ||
|
|
e12abf3114 | ||
|
|
4ad9622efb | ||
|
|
2f0dd9c4ee | ||
|
|
2af497495f | ||
|
|
056b3e40d6 | ||
|
|
6402a48482 | ||
|
|
2dfd3b9a81 | ||
|
|
7b6cbf5869 | ||
|
|
8686b3b951 | ||
|
|
2e7e135bc0 | ||
|
|
c287664923 | ||
|
|
18f0051d26 | ||
|
|
b012b1105e | ||
|
|
21370fc09b | ||
|
|
4999f15688 | ||
|
|
e4f9555f21 | ||
|
|
243a8b019e | ||
|
|
5c4079f66c | ||
|
|
b1f086b536 | ||
|
|
d298b8c16b | ||
|
|
40968bd5e0 | ||
|
|
80e6c070bf | ||
|
|
26fcca087b | ||
|
|
02ca148583 | ||
|
|
ae1c6f4313 | ||
|
|
9faed2226a | ||
|
|
cf04b24632 | ||
|
|
7e59c15496 | ||
|
|
9f856abfe7 | ||
|
|
e74fd9196c | ||
|
|
40e928a4c4 | ||
|
|
079af0d0b0 | ||
|
|
faa5838147 | ||
|
|
f6abe62e5f | ||
|
|
5c5745dee5 | ||
|
|
15c735de4d | ||
|
|
8bf484bdad | ||
|
|
36719690a2 | ||
|
|
f2666d2092 | ||
|
|
a28c271488 | ||
|
|
1d9d5b30ce | ||
|
|
14f56a4e18 | ||
|
|
687c41e838 | ||
|
|
ddb7b5c6a4 | ||
|
|
262e35c219 | ||
|
|
95f0befd65 | ||
|
|
83d5e30027 | ||
|
|
842be7b864 | ||
|
|
cb5d76ed3d | ||
|
|
3d5ffee07f | ||
|
|
bd8f4b052d | ||
|
|
929d50b7d1 | ||
|
|
4fda10c508 | ||
|
|
0b0d8b2406 | ||
|
|
844ff2ee8f | ||
|
|
8c666666ef | ||
|
|
2394703593 | ||
|
|
404470853a | ||
|
|
99fc0fbac1 | ||
|
|
91ed00f800 | ||
|
|
76698ed296 | ||
|
|
716546824f | ||
|
|
74f382f732 | ||
|
|
a76aea1bc0 | ||
|
|
533766207f | ||
|
|
59fa002561 | ||
|
|
48ab168df2 | ||
|
|
bef9d5bdc8 | ||
|
|
c6812c6af4 | ||
|
|
1f7cb4b853 | ||
|
|
d161f3ab0f | ||
|
|
c65b91c841 | ||
|
|
760b1e8fc6 | ||
|
|
188893f319 | ||
|
|
04ee9e7765 | ||
|
|
390ba5f42a | ||
|
|
b8593fd4fb | ||
|
|
68a467dd66 | ||
|
|
d18319a57d | ||
|
|
15e5bb3459 | ||
|
|
41f6d06967 | ||
|
|
e3a99aa2ce | ||
|
|
c1d8456860 | ||
|
|
da10ca1585 | ||
|
|
5d017dae5a | ||
|
|
30fd7001f2 | ||
|
|
da4b124480 | ||
|
|
710c681283 | ||
|
|
e45228ac37 | ||
|
|
a0180f364d | ||
|
|
d69f246ba7 | ||
|
|
a81989048d | ||
|
|
b56e9964f5 | ||
|
|
ddd7fc1513 | ||
|
|
4ebf55f1db | ||
|
|
cc24ede586 | ||
|
|
eb3b84f3d2 | ||
|
|
304244f2be | ||
|
|
f067ea25b4 | ||
|
|
fa51294f65 | ||
|
|
a4d1c4d522 | ||
|
|
6e17c463ae | ||
|
|
63797e841d | ||
|
|
fdb171cb15 | ||
|
|
6f9861bb9b | ||
|
|
759068304e | ||
|
|
ded578b1fa | ||
|
|
dcb8d16591 | ||
|
|
06c17a333e | ||
|
|
409a16060b | ||
|
|
7720106624 | ||
|
|
c613769d22 | ||
|
|
87343c374e | ||
|
|
67be9aed28 | ||
|
|
b48d5d96d3 | ||
|
|
d8cc7db5e6 | ||
|
|
dfbf6ac263 | ||
|
|
121ae6036b | ||
|
|
6e1ad31b49 | ||
|
|
b8b0b3f0e7 | ||
|
|
0330b483ad | ||
|
|
9a2bf57e1c | ||
|
|
439044068a | ||
|
|
4c3b4aeb76 | ||
|
|
1e8b291374 | ||
|
|
95f82154f7 | ||
|
|
7bc3998451 | ||
|
|
d029ceab1c | ||
|
|
c331bdc27d | ||
|
|
b0b42b4e14 | ||
|
|
e5514d4854 | ||
|
|
20bc89d96c | ||
|
|
199fef2a5e | ||
|
|
d9a2ac7e72 | ||
|
|
a16934b2ab | ||
|
|
14a072f5fa | ||
|
|
574b848863 | ||
|
|
2e6c58bf75 | ||
|
|
a5d89e6eb1 | ||
|
|
61907ddf3e | ||
|
|
1eab8fa9b0 | ||
|
|
2cf444be02 | ||
|
|
7870ce8177 | ||
|
|
e9d691d472 | ||
|
|
ac2fcfe96a | ||
|
|
627fa3083b | ||
|
|
e4877656ca | ||
|
|
d91f0ceeb3 | ||
|
|
9b71382efb | ||
|
|
dd82d32d85 | ||
|
|
5a42f7cabd | ||
|
|
f2c25c5f40 | ||
|
|
528524e4c7 | ||
|
|
7e08de4a5f | ||
|
|
c9e3c14f9c | ||
|
|
636a8e3181 | ||
|
|
660f87278c | ||
|
|
9ec1fb4a80 | ||
|
|
74757cd5af | ||
|
|
9f75550702 | ||
|
|
8214ab507c | ||
|
|
2f6d5805de | ||
|
|
48f733e4b3 | ||
|
|
e5f7435d9f | ||
|
|
322c8dc4fc | ||
|
|
cf04b0e3bf | ||
|
|
0f9f510dd9 | ||
|
|
e110cf4fb1 | ||
|
|
6942ceb7a9 | ||
|
|
47cf28f6b6 | ||
|
|
115b4379bf | ||
|
|
a6db1edee3 | ||
|
|
4ed1b7c7ed | ||
|
|
7ef7b94bc0 | ||
|
|
da7da30b22 | ||
|
|
213d9b47b0 | ||
|
|
d88b239d3c | ||
|
|
1c02de1309 | ||
|
|
d3c2b83f88 | ||
|
|
93f80894a3 | ||
|
|
c440cc2f84 | ||
|
|
8ddedc3fc5 | ||
|
|
2439c31844 | ||
|
|
292f21ae78 | ||
|
|
9c2c4b1138 | ||
|
|
e0e33e12d1 | ||
|
|
eb5145c5d1 | ||
|
|
aedf4ce328 | ||
|
|
94af5a72fc | ||
|
|
9dbc1435a6 | ||
|
|
32a668e4d9 | ||
|
|
c17c7b4e24 | ||
|
|
c5e732951b | ||
|
|
069b50635b | ||
|
|
074db1905a | ||
|
|
9609a3af40 | ||
|
|
476087f879 | ||
|
|
41b696fa83 | ||
|
|
a5adedea91 | ||
|
|
9266e542ab | ||
|
|
f06ad4502b | ||
|
|
e26c647828 | ||
|
|
51f1f23235 | ||
|
|
c9d02f0132 | ||
|
|
bee72f1ae0 | ||
|
|
509bc81e28 | ||
|
|
6d5195c890 | ||
|
|
11b9b6dba5 | ||
|
|
bbc67f3754 | ||
|
|
4f02c74dca | ||
|
|
a5afe7bc2b | ||
|
|
f4b03599f0 | ||
|
|
7f6e87e918 | ||
|
|
e14ff8f407 | ||
|
|
87d995bcde | ||
|
|
44d55667de | ||
|
|
3e546e691d | ||
|
|
52e9450a79 | ||
|
|
aa7c291ad6 | ||
|
|
3f44f4167b | ||
|
|
90a6ec12cc | ||
|
|
a91c653d8a | ||
|
|
7f6fcbf637 | ||
|
|
5d7e38a786 | ||
|
|
d5ffc672dd | ||
|
|
638fdad048 | ||
|
|
2dee177c9b | ||
|
|
a793523b74 | ||
|
|
5b0684ebcf | ||
|
|
ffe6d9ad54 | ||
|
|
d4df747f9f | ||
|
|
8e33bd8610 | ||
|
|
3036c38144 | ||
|
|
d72fc1ce7f | ||
|
|
c6ef7ff921 | ||
|
|
44c61a77c5 | ||
|
|
4bac76e66d | ||
|
|
0bd99717be | ||
|
|
39dfdccf6c | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
754494d1a0 | ||
|
|
37af1d6946 | ||
|
|
90ea21536b | ||
|
|
3690be9419 | ||
|
|
079c29ceb8 | ||
|
|
c7808a543d | ||
|
|
1aed588743 | ||
|
|
0af4eda8c5 | ||
|
|
cf2fe4b4c5 | ||
|
|
5df58e404f | ||
|
|
ef352d4dc6 | ||
|
|
cb2add8459 | ||
|
|
d9c20f6fa5 | ||
|
|
79c93b2cf8 | ||
|
|
48bfaa2371 | ||
|
|
0372bdf6fe | ||
|
|
56316ad932 | ||
|
|
ba2514fc4c | ||
|
|
cd8309cc31 | ||
|
|
9e06d945a2 | ||
|
|
c3a34408f3 | ||
|
|
9f280454ba | ||
|
|
588dc43787 | ||
|
|
b826bd668c | ||
|
|
e6a4cf01ee | ||
|
|
fcea6303ed | ||
|
|
9292ec9880 | ||
|
|
35e7c62e78 | ||
|
|
66193dab92 | ||
|
|
4609ed70c1 | ||
|
|
428241d941 | ||
|
|
adfb000587 | ||
|
|
3776de906f | ||
|
|
73afbc9193 | ||
|
|
795985d339 | ||
|
|
2f8206862a | ||
|
|
b347d5d9cc | ||
|
|
73e9e787b4 | ||
|
|
47d1f23d55 | ||
|
|
5d9a5b7958 | ||
|
|
c21469b282 | ||
|
|
10a0c96ee6 | ||
|
|
e071493bb3 | ||
|
|
2dc9c95530 | ||
|
|
d126e7f610 | ||
|
|
5ee03c82b4 | ||
|
|
111aeb2c4f | ||
|
|
23c2c638b7 | ||
|
|
6b8299eb33 | ||
|
|
9822a53649 | ||
|
|
81d392a2d7 | ||
|
|
dbcec3ffaf | ||
|
|
50c5231486 | ||
|
|
55d034358d | ||
|
|
374da34936 | ||
|
|
c578fca687 | ||
|
|
dd18765b50 | ||
|
|
3e06fe84dc | ||
|
|
640e19988f | ||
|
|
80dae2e5e8 | ||
|
|
60b87826bb | ||
|
|
b04b51d2c4 | ||
|
|
de33bc70e7 | ||
|
|
0c8ba6599b | ||
|
|
d1e9490f95 | ||
|
|
cb7edb669f | ||
|
|
a9fc2ca0ef | ||
|
|
dd1b08b3e8 | ||
|
|
af1004ebbd | ||
|
|
ed909d6013 | ||
|
|
f3516fb316 | ||
|
|
79d8267413 | ||
|
|
99bf65c539 | ||
|
|
6a4b5fa4b5 | ||
|
|
83511c0c09 | ||
|
|
1fec41b3df | ||
|
|
c532d161c4 | ||
|
|
989543c9c3 | ||
|
|
145adf540f | ||
|
|
953472bf25 | ||
|
|
d9384785a3 | ||
|
|
2f6b5ffdfe | ||
|
|
0e3c9e4a0e | ||
|
|
15311c138a | ||
|
|
dec71dbcf1 | ||
|
|
5a4482412d | ||
|
|
4cf829608c | ||
|
|
8de02e6074 | ||
|
|
d802844bd6 | ||
|
|
e97bcf4dae | ||
|
|
dad8e11f1e | ||
|
|
50fdd514ae | ||
|
|
ee36e12f81 | ||
|
|
1e5569d56a | ||
|
|
3ce1ee84ac | ||
|
|
1ea3ac0a1d | ||
|
|
66b6c9e0e5 | ||
|
|
b5e99dad1f | ||
|
|
6f5205d826 | ||
|
|
5f975a4eff | ||
|
|
aadfdbc59f | ||
|
|
d5c8172197 | ||
|
|
9e804f6f40 | ||
|
|
bedfc3642d | ||
|
|
46dcda1d0c | ||
|
|
950f8a04ea | ||
|
|
de44e0ad33 | ||
|
|
c639b386da | ||
|
|
fac0110e49 | ||
|
|
97971f3aef | ||
|
|
acb523de86 | ||
|
|
97531f174f | ||
|
|
ef125d5ba7 | ||
|
|
86950d3474 | ||
|
|
3a9582bc41 | ||
|
|
d198474415 | ||
|
|
ace8a1b44e | ||
|
|
a7be3a9649 | ||
|
|
0d543dd1ff | ||
|
|
404c373153 | ||
|
|
024691e4e7 | ||
|
|
a86d7a2f35 | ||
|
|
e7e34c442e | ||
|
|
9b9e8d4ae8 | ||
|
|
bf925e5758 | ||
|
|
c0c9df4ab7 | ||
|
|
6aa90f8b18 | ||
|
|
9af1c8a886 | ||
|
|
ed5ece4120 | ||
|
|
85d1835476 | ||
|
|
e85d2dff97 | ||
|
|
fac66d4dda | ||
|
|
2e99369113 | ||
|
|
835f9ee575 | ||
|
|
a136c6aa89 | ||
|
|
af96bac2dd | ||
|
|
b621d4550b | ||
|
|
9497ffcc50 | ||
|
|
c290217305 | ||
|
|
769b286cf2 | ||
|
|
690bb192e6 | ||
|
|
601a052216 | ||
|
|
bf3021d266 | ||
|
|
032c780a79 | ||
|
|
145b2e5f52 | ||
|
|
4b73dc95c4 | ||
|
|
e17cb408a5 | ||
|
|
3cf92152c3 | ||
|
|
c50cde2170 | ||
|
|
7e0bebd669 | ||
|
|
7c49326191 | ||
|
|
744d1329cb | ||
|
|
42e6ff4611 | ||
|
|
5f21bf735a | ||
|
|
ee380e9ab9 | ||
|
|
ab340c82fb | ||
|
|
57dd0505a3 | ||
|
|
d6f9f1c79a | ||
|
|
a08a772ffc | ||
|
|
2622b1936b | ||
|
|
c0457e0cc4 | ||
|
|
ee2f0a175a | ||
|
|
0e94f0c018 | ||
|
|
576485b0c9 | ||
|
|
60efe8ed7b | ||
|
|
332a20d9cc | ||
|
|
57bf6d5eaf | ||
|
|
f16b0cf80d | ||
|
|
a4ee933022 | ||
|
|
cf7437cb4c | ||
|
|
081123c0e4 | ||
|
|
5fe3c36471 | ||
|
|
e06158c645 | ||
|
|
19a8547ecd | ||
|
|
32ae4566c6 | ||
|
|
be6a3d4caf | ||
|
|
1db0384090 | ||
|
|
d024dceef7 | ||
|
|
5ec499e14c | ||
|
|
0b350d78d5 | ||
|
|
96ee027371 | ||
|
|
ffcf3263c1 | ||
|
|
cef6b16d14 | ||
|
|
d06d440086 | ||
|
|
415fc9092e | ||
|
|
0be9d773cb | ||
|
|
ecb45660e9 | ||
|
|
f6fefd7f5f | ||
|
|
4206b9684b | ||
|
|
a4aad1c76a | ||
|
|
9464774133 | ||
|
|
be7191879a | ||
|
|
7252938339 | ||
|
|
810394f43b | ||
|
|
835162fb62 | ||
|
|
82883095fe | ||
|
|
49d8ad3049 | ||
|
|
1721d04405 | ||
|
|
633e0d9382 | ||
|
|
e156320c51 | ||
|
|
f06ce98312 | ||
|
|
b546b2a48d | ||
|
|
c11b016d22 | ||
|
|
3686bde783 | ||
|
|
9c06689569 | ||
|
|
891a2cc64a | ||
|
|
01211937fc | ||
|
|
4726580c7e | ||
|
|
e9a08dc507 | ||
|
|
f3698e360b | ||
|
|
c69947dff8 | ||
|
|
173bce34b0 | ||
|
|
6a27e385b1 | ||
|
|
5f0d9c3eb9 | ||
|
|
0e31c8153c | ||
|
|
9c0773c469 | ||
|
|
f5533baf61 | ||
|
|
60bc436e99 | ||
|
|
741b984a68 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
f3ef609839 | ||
|
|
234fe5b5cd | ||
|
|
65710932ff | ||
|
|
e944f21ec0 | ||
|
|
ee6e534ccb | ||
|
|
11b07f4a29 | ||
|
|
676d41d415 | ||
|
|
a3a4996adb | ||
|
|
b015c7e5ad | ||
|
|
4de3c3a028 | ||
|
|
b739a3897f | ||
|
|
c5e19f5c67 | ||
|
|
9241e21114 | ||
|
|
65bed815a8 | ||
|
|
d776cfb4e1 | ||
|
|
c6e7e1821b | ||
|
|
f76ab69612 | ||
|
|
889db137b8 | ||
|
|
9db682750d | ||
|
|
9809b47d45 | ||
|
|
68d79e56c2 | ||
|
|
d3862ae30a | ||
|
|
e49a2952d9 | ||
|
|
8b57f519c3 | ||
|
|
ddcc05f5f4 | ||
|
|
8c0e290db1 | ||
|
|
7bfc77db25 | ||
|
|
8d74578ceb | ||
|
|
f7123ec30a | ||
|
|
ad4f4388f4 | ||
|
|
2a86504723 | ||
|
|
de3b68740a | ||
|
|
efaa73f543 | ||
|
|
1589c73697 | ||
|
|
359d2af8a8 | ||
|
|
fa897e5dfe | ||
|
|
7fa8ae56cb | ||
|
|
ec27c813cc | ||
|
|
3b24fe639a | ||
|
|
e5cca6e432 | ||
|
|
ae0b4c4990 | ||
|
|
49bd2d96fa | ||
|
|
ca350fc66c | ||
|
|
30338ce1a7 | ||
|
|
faa94f0168 | ||
|
|
f5c84768ff | ||
|
|
df752d4706 | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ebfeb7a6bf | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 | ||
|
|
f5f7f47c81 | ||
|
|
d4bd387e0e | ||
|
|
d1c85cb32d | ||
|
|
a3a2c641a7 | ||
|
|
54d7551b53 | ||
|
|
e2c10a2b7a | ||
|
|
88b37e80fc | ||
|
|
d5be8fa576 | ||
|
|
208398973b | ||
|
|
8f998741b7 | ||
|
|
9c0ff87c86 | ||
|
|
1a0d1cb7b2 | ||
|
|
cf8b3ed988 | ||
|
|
b7575a889e | ||
|
|
154d4a43db | ||
|
|
b5c023044b | ||
|
|
072a13f3b2 | ||
|
|
c00ea63bb0 | ||
|
|
8350758635 | ||
|
|
2dabce59ce | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 | ||
|
|
e7a4931932 | ||
|
|
ad3c12a43a | ||
|
|
7e2d91f3b7 | ||
|
|
97cef49046 | ||
|
|
016693a1f5 | ||
|
|
89c5185f1c | ||
|
|
b105745299 | ||
|
|
1ae415e395 | ||
|
|
e011c764a7 | ||
|
|
55aff22274 | ||
|
|
e4e1396a98 | ||
|
|
331b8157b0 | ||
|
|
efdb33c975 | ||
|
|
fa1079214b | ||
|
|
82e49af5a7 | ||
|
|
fabc2882aa | ||
|
|
b2650ba672 | ||
|
|
6b3d3f5e21 | ||
|
|
147fccd967 | ||
|
|
6da6582ced | ||
|
|
45bf07ba31 | ||
|
|
50ae43f886 | ||
|
|
afb877a96b | ||
|
|
0d9172d761 | ||
|
|
dad69afc84 | ||
|
|
787bed4996 | ||
|
|
b6d470a679 | ||
|
|
5fa1a63978 | ||
|
|
6cc57ae772 | ||
|
|
0f6f7059d9 | ||
|
|
67f63ecd7e | ||
|
|
1420d113d8 | ||
|
|
5b4651d9ed | ||
|
|
5f22b68268 | ||
|
|
34590d2144 | ||
|
|
0c93b9b7bb | ||
|
|
b659db0a5b | ||
|
|
9fd9f4c896 | ||
|
|
005b831023 | ||
|
|
8013c4717c | ||
|
|
14e6b21b50 | ||
|
|
62354dff9c | ||
|
|
ccb30665f7 | ||
|
|
0fb2777c6d | ||
|
|
568b8ee96c | ||
|
|
fc60699f03 | ||
|
|
c1da78a271 | ||
|
|
0674f1fa3c | ||
|
|
448394a0de | ||
|
|
3a0fd6be3c | ||
|
|
8b1bec11d0 | ||
|
|
f73dbdbaea | ||
|
|
05f49d2846 | ||
|
|
1d83389776 | ||
|
|
e0e8f11f70 | ||
|
|
36d88f6079 | ||
|
|
2c070952e1 | ||
|
|
fc45148155 | ||
|
|
215c395fc2 | ||
|
|
b56b67cdbd | ||
|
|
a760db9921 | ||
|
|
8eb80ee40a | ||
|
|
f9e3b129ed | ||
|
|
e5050abe2a | ||
|
|
4f0771f67b | ||
|
|
075ff675ac | ||
|
|
c7ea47e886 | ||
|
|
b543339373 | ||
|
|
22c7f659f6 | ||
|
|
79a44d0da4 | ||
|
|
d593a809f0 | ||
|
|
22add31e91 | ||
|
|
b44d740720 | ||
|
|
4d590f9254 | ||
|
|
a5aa48beea | ||
|
|
1bf3861ca4 | ||
|
|
ff9d069a33 | ||
|
|
f8052be369 | ||
|
|
a08438ae97 | ||
|
|
fe00d6aacf | ||
|
|
984692cda2 | ||
|
|
4c12c4fc04 | ||
|
|
794bab45ff | ||
|
|
16e5fa1db9 | ||
|
|
125be3e111 | ||
|
|
b60a53e10d | ||
|
|
9de762faa2 | ||
|
|
5aed38eebc | ||
|
|
e63e483c38 | ||
|
|
277e43e32c | ||
|
|
852aa16ca0 | ||
|
|
82b7153ac1 | ||
|
|
7d2e510087 | ||
|
|
9ca4c10e59 | ||
|
|
a31a79396b | ||
|
|
acc3eb11d0 | ||
|
|
9d9fff2991 | ||
|
|
030ed5d592 | ||
|
|
f6d359932a | ||
|
|
3200b51160 | ||
|
|
4b11ebb30e | ||
|
|
40345642fa | ||
|
|
e932772230 | ||
|
|
63d466fe5e | ||
|
|
c2fada7062 | ||
|
|
d9c29f5ce5 | ||
|
|
f5d5ef6857 | ||
|
|
361a17415f | ||
|
|
fb393c3c51 | ||
|
|
e0158c5d5d | ||
|
|
be12b0771c | ||
|
|
1309fc1f48 | ||
|
|
4fdecfb845 | ||
|
|
31c6f178f3 | ||
|
|
1e2ab8bf1e | ||
|
|
35a1d81518 | ||
|
|
1c4297d8b5 | ||
|
|
e3638a9a9e | ||
|
|
1f8558771a | ||
|
|
2e231d09ec | ||
|
|
727c07bd88 | ||
|
|
c32ad19377 | ||
|
|
ef40ab2933 | ||
|
|
e71fa4a145 | ||
|
|
a7c0887f94 | ||
|
|
53218b91c6 | ||
|
|
2d4de656d2 | ||
|
|
b0f44acf9e | ||
|
|
a828e60067 | ||
|
|
96df70fccf | ||
|
|
0e49dca53c | ||
|
|
8ec4af4641 | ||
|
|
2f6d9417bd | ||
|
|
534a012a4e | ||
|
|
7a3fa9ce03 | ||
|
|
8a67d29748 | ||
|
|
408f4f2dac | ||
|
|
3df2dc0b15 | ||
|
|
5304a8c2d1 | ||
|
|
1569d29b2d | ||
|
|
50c8e74230 | ||
|
|
1045b032a2 | ||
|
|
a813343aa7 | ||
|
|
5a08471dcd | ||
|
|
252dfbcd40 | ||
|
|
75588fe732 | ||
|
|
9bbdeb3d52 | ||
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
30c945fe92 | ||
|
|
dfd511c310 | ||
|
|
1657525201 | ||
|
|
3e4b0d0505 | ||
|
|
003c6c9ae1 | ||
|
|
41fbb4d8b0 | ||
|
|
eecb340f64 | ||
|
|
d029eaa0bb | ||
|
|
9c7dcc1ed7 | ||
|
|
be37b39782 | ||
|
|
49c35c752c | ||
|
|
25d8043b9d | ||
|
|
f9f4a953fc | ||
|
|
34c3fbc66c | ||
|
|
a9f21b3d3a | ||
|
|
ed5c5629f6 | ||
|
|
868952f958 | ||
|
|
9b9836be71 | ||
|
|
22cd839cb2 | ||
|
|
dc3ac9fa28 | ||
|
|
c874fa9712 | ||
|
|
6b784a9771 | ||
|
|
f8e673cdbc | ||
|
|
ad360b4d18 | ||
|
|
69ba2765de | ||
|
|
31e8ecca10 | ||
|
|
4ca38286d8 | ||
|
|
fbf1c3ca3c | ||
|
|
1a4313c2aa | ||
|
|
a6deb0d9d5 | ||
|
|
b6ea5895b6 | ||
|
|
d66bc65ca6 | ||
|
|
89f85ddeab | ||
|
|
bbb71c9198 | ||
|
|
ae6792522d | ||
|
|
e637bbdfb5 | ||
|
|
869ef0c5ba | ||
|
|
1002c74d9c | ||
|
|
61e60f3b84 | ||
|
|
13b931c006 | ||
|
|
ab49fe0e92 | ||
|
|
d0bc08a934 | ||
|
|
64d21f5ea8 | ||
|
|
0a95d8a840 | ||
|
|
56f3a2de25 | ||
|
|
d8b463d0b3 | ||
|
|
eef3df9fa5 | ||
|
|
837eea4ebd | ||
|
|
55622bac06 | ||
|
|
f172ccfcf6 | ||
|
|
a2a6893566 | ||
|
|
616ee3075c | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
cddf198321 | ||
|
|
6d969fe58e | ||
|
|
68c7d577a4 | ||
|
|
1ea8917e2b | ||
|
|
07c93dfd30 | ||
|
|
cf0ea6c756 | ||
|
|
8c9e32c4a3 | ||
|
|
34d59d7913 | ||
|
|
0c0d9e1d22 | ||
|
|
2ee45d50a4 | ||
|
|
0c0e1e4226 | ||
|
|
86a46874da | ||
|
|
3a6ee5ee00 | ||
|
|
5dc87a2ed4 | ||
|
|
a85ddf258c | ||
|
|
1f3a09b43b | ||
|
|
7b31b280f8 | ||
|
|
6a3ed5c850 | ||
|
|
7ed55682b7 | ||
|
|
37a2eee837 | ||
|
|
353d778988 | ||
|
|
5a1ff5b9e7 | ||
|
|
3dc4a96330 | ||
|
|
65a8a93854 | ||
|
|
eb8a0510e0 | ||
|
|
a4178e4062 | ||
|
|
e7953d8164 | ||
|
|
5ebfc0738f | ||
|
|
bd32cc40e6 | ||
|
|
b31d8d3b10 | ||
|
|
331141ad77 | ||
|
|
c7ae5100fa | ||
|
|
285ed8bac3 | ||
|
|
e59d8c5436 | ||
|
|
8b42902cee | ||
|
|
07a3db153d | ||
|
|
68d35be383 | ||
|
|
99dd428862 | ||
|
|
4d314db750 | ||
|
|
ccea3a0615 | ||
|
|
f4f20c6762 | ||
|
|
a624878973 | ||
|
|
c8b826ea8c | ||
|
|
f7089cde54 | ||
|
|
f42b12646d | ||
|
|
572e04d5fb | ||
|
|
bc49c20434 | ||
|
|
4b085f23e0 | ||
|
|
c4ea25a509 | ||
|
|
312cb75c50 | ||
|
|
ee738e6578 | ||
|
|
fcb7c9ff65 | ||
|
|
49ecbd8fea | ||
|
|
19ee6699d2 | ||
|
|
5fcc9b3244 | ||
|
|
3efc5e54fa | ||
|
|
780c811146 | ||
|
|
4f37f66264 | ||
|
|
8ebfa2950d | ||
|
|
9a60d431c5 | ||
|
|
6e4d86f426 | ||
|
|
87cecd0268 | ||
|
|
388b2bce01 | ||
|
|
a2b5b1f0cb | ||
|
|
9f4b7a1683 | ||
|
|
dd68faef23 | ||
|
|
97cfa0846c | ||
|
|
1b973f7506 | ||
|
|
4b749f1b8f | ||
|
|
7f1f9473a0 | ||
|
|
a32e5d040c | ||
|
|
a82217a5f3 | ||
|
|
09bed2ccde | ||
|
|
3af391eec7 | ||
|
|
204309dd3c | ||
|
|
3fcd6fadf3 | ||
|
|
78136c4368 | ||
|
|
25edbdacc2 | ||
|
|
451532f6f1 | ||
|
|
bc7d603867 | ||
|
|
46015a3dd8 | ||
|
|
1481a3d90f | ||
|
|
96a1d03f08 | ||
|
|
0291105913 | ||
|
|
dbf8829283 | ||
|
|
02184dd055 | ||
|
|
d5332ae29a | ||
|
|
4ba6f6e8ee | ||
|
|
fdaeada3ec | ||
|
|
3fb699a84b | ||
|
|
767f55b127 | ||
|
|
20897e943f | ||
|
|
2d1078fc52 | ||
|
|
19016f16e0 | ||
|
|
b8e3725106 | ||
|
|
413dfc6d6d | ||
|
|
faba508fe0 | ||
|
|
a76cbc43bb | ||
|
|
e16ce1a0a1 | ||
|
|
fa2b92bb00 | ||
|
|
c592f395df | ||
|
|
f14d622c0f | ||
|
|
99aba3a5c4 | ||
|
|
58e02087b5 | ||
|
|
fd49f39a72 | ||
|
|
bbef30daa5 | ||
|
|
c8b865d582 | ||
|
|
4b7c6d4f8f | ||
|
|
c22d2b2ffd | ||
|
|
0179717d61 | ||
|
|
abf4c02a0d | ||
|
|
03a9907055 | ||
|
|
66c99e1608 | ||
|
|
15a95f988a | ||
|
|
7ecf733342 | ||
|
|
3ec221c70e | ||
|
|
cc2d617ea6 | ||
|
|
503aad1417 | ||
|
|
1ad26d6fea | ||
|
|
02a4de0029 | ||
|
|
bcfc9bead5 | ||
|
|
1be0e9b9fb | ||
|
|
6e5eddf292 | ||
|
|
64a2ef4a18 | ||
|
|
25399d39cb | ||
|
|
731080375a | ||
|
|
89bbbe75a6 | ||
|
|
f69298d7eb | ||
|
|
6280305899 | ||
|
|
543d1ea3c1 | ||
|
|
1569db1754 | ||
|
|
c54c665f97 | ||
|
|
a84000c6d9 | ||
|
|
a979a62f8e | ||
|
|
13e2dd97a7 | ||
|
|
6bba84b043 | ||
|
|
e31251293b | ||
|
|
7a9ff18260 | ||
|
|
56ed5cc2d9 | ||
|
|
af31e0d969 | ||
|
|
37fa4f7eef | ||
|
|
9aad6dfe1b | ||
|
|
6b8db36a15 | ||
|
|
171060541a | ||
|
|
003547c818 | ||
|
|
c4e1064066 | ||
|
|
d163dbcfcd | ||
|
|
2acfeb1096 | ||
|
|
b3b6d421cc | ||
|
|
cf72b9db3c | ||
|
|
106e308953 | ||
|
|
bf72a126d1 | ||
|
|
28a5d124c3 | ||
|
|
b4045e6adb | ||
|
|
a7bec3340f | ||
|
|
a4e99ecdaf | ||
|
|
dcd20d564f | ||
|
|
59f6ea9b21 | ||
|
|
e44f28bd4f | ||
|
|
929b86e302 | ||
|
|
76d3d58b5c | ||
|
|
548a32c8d4 | ||
|
|
500c75b4f0 | ||
|
|
3567dc4a47 | ||
|
|
7df37c2dbd | ||
|
|
28a4cbc4ef | ||
|
|
21fe4d9ded | ||
|
|
05d149a49b | ||
|
|
97a41a6509 | ||
|
|
a0be85c34c | ||
|
|
a36735b913 | ||
|
|
390bd11f33 | ||
|
|
16768a9998 | ||
|
|
e9d6869290 | ||
|
|
9072e35f08 | ||
|
|
d887027e95 | ||
|
|
168a0f4998 | ||
|
|
f3a37664d5 | ||
|
|
8bcbe68637 | ||
|
|
beb9eac5f7 | ||
|
|
0dcffcd5b0 | ||
|
|
56efbce31e | ||
|
|
e7c42884fc | ||
|
|
08c0405f0f | ||
|
|
470add877c | ||
|
|
aaa310c047 | ||
|
|
0cd24137e8 | ||
|
|
8ffb8cc363 | ||
|
|
7a9854cb06 | ||
|
|
5ee4456c6e | ||
|
|
de31583021 | ||
|
|
38b49aa0f6 | ||
|
|
69761e8a51 | ||
|
|
3431d3d115 | ||
|
|
fe9e027d58 | ||
|
|
33d17957e5 | ||
|
|
25ae5f897e | ||
|
|
624ff09314 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
9e39a56033 | ||
|
|
026cf1130e | ||
|
|
bb14b19922 | ||
|
|
78279fb758 | ||
|
|
38cf90f6db | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a | ||
|
|
0d6af15d1c | ||
|
|
eda9410bce | ||
|
|
a51ed8a5dd | ||
|
|
19bcbf85df | ||
|
|
f49d0e5476 | ||
|
|
9c4c9c5edd | ||
|
|
0f34255359 | ||
|
|
de5fb65cb8 | ||
|
|
e773f84e39 | ||
|
|
b969c216fc | ||
|
|
f51a4d2aca | ||
|
|
30b3a9de30 | ||
|
|
0391f6553b | ||
|
|
d0c986c4f0 | ||
|
|
384028e12e | ||
|
|
4c14d6c8db | ||
|
|
6fa437613b | ||
|
|
949fa1051f | ||
|
|
4965727f39 | ||
|
|
7c34883267 | ||
|
|
072c3dc55c | ||
|
|
83b3875131 | ||
|
|
a35083808c | ||
|
|
3d3ec9d972 | ||
|
|
9838a2850f | ||
|
|
6c6bc6ff1c | ||
|
|
1791c1a765 | ||
|
|
6e53c061ff | ||
|
|
1773f8aea2 | ||
|
|
52bdf57743 | ||
|
|
b3ab24eb8e | ||
|
|
6ac1c1d6ea | ||
|
|
7655a501d0 | ||
|
|
3b1b14b0b1 | ||
|
|
bf15c87d2b | ||
|
|
2dae4d382f | ||
|
|
e9a47a02d1 | ||
|
|
929666a8c8 | ||
|
|
cd409e5667 | ||
|
|
8e80823b03 | ||
|
|
319afd192d | ||
|
|
6e0daf0936 | ||
|
|
d0cb4e092f | ||
|
|
f5a881c99d | ||
|
|
66377fc030 | ||
|
|
d8d295b0b3 | ||
|
|
ef36e24522 | ||
|
|
d43d4fcced | ||
|
|
d42b69df74 | ||
|
|
1ec1f6dcbf | ||
|
|
e96b939732 | ||
|
|
e479c870fd | ||
|
|
f2db894685 | ||
|
|
4f03283126 | ||
|
|
ed7dec0975 | ||
|
|
fbb3da506f | ||
|
|
dfa6c5c2b3 | ||
|
|
028eed5fe8 | ||
|
|
2b16a87f04 | ||
|
|
731049936d | ||
|
|
5a5b058ba0 | ||
|
|
72f28be648 | ||
|
|
ff645524d8 | ||
|
|
c534390bc0 | ||
|
|
c5003e5441 | ||
|
|
0b3ebb0c63 | ||
|
|
23981496f9 | ||
|
|
f2e425dc2b | ||
|
|
e48d68bbc7 | ||
|
|
842fc8d08b | ||
|
|
54ec14262b | ||
|
|
d0c70178e0 | ||
|
|
3bc9c330eb | ||
|
|
b7fcc8584f | ||
|
|
2b8ce3f06b | ||
|
|
41d44021e7 | ||
|
|
1ab1e312b2 | ||
|
|
fa9aafce83 | ||
|
|
0d5dec4c66 | ||
|
|
b2d5889f6e | ||
|
|
2ee71e4154 | ||
|
|
1656f491fd | ||
|
|
a057b3c9e8 | ||
|
|
236b27cb3a | ||
|
|
1634abf293 | ||
|
|
ca9688b5cc | ||
|
|
e6364d031d | ||
|
|
01c8d099ad | ||
|
|
f6e619f078 | ||
|
|
b2b331230b | ||
|
|
c0c9742e44 | ||
|
|
abcca86e4e | ||
|
|
a5d8f89b53 | ||
|
|
a0d2a7232e | ||
|
|
f449115ec5 | ||
|
|
16bc4cdef3 | ||
|
|
23e4ba845c | ||
|
|
d9f9e93dee | ||
|
|
dae34f3a61 | ||
|
|
af61b353a4 | ||
|
|
29476b222d | ||
|
|
e7c16cc0e6 | ||
|
|
3dddbe1053 | ||
|
|
2dea6bfa7e | ||
|
|
04e3bfed35 | ||
|
|
3e32050601 | ||
|
|
7fb45ed9b8 | ||
|
|
b7ba94f0c1 | ||
|
|
5b827528f8 | ||
|
|
409e33d9c2 | ||
|
|
747277d914 | ||
|
|
068dca3366 | ||
|
|
336a1ad9cf | ||
|
|
d42f767d0c | ||
|
|
9b8ae62399 | ||
|
|
7c38b535f6 | ||
|
|
af370ab23e | ||
|
|
1210657fda | ||
|
|
12afec953f | ||
|
|
8e2707e232 | ||
|
|
8befe7f8a7 | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
509215e935 | ||
|
|
f726656d1e | ||
|
|
30d3e1da21 | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
10eb1beccf | ||
|
|
a4b347b454 | ||
|
|
688a0ce439 | ||
|
|
6146acbb69 | ||
|
|
35492f8513 | ||
|
|
db9be87d94 | ||
|
|
4a99b9b651 | ||
|
|
7dea403302 | ||
|
|
10731dfee3 | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
2d1ae0916f | ||
|
|
d0455f2683 | ||
|
|
f25fe032c4 | ||
|
|
0a5b1fbcd1 | ||
|
|
8daab932a2 | ||
|
|
3b7b45c29e | ||
|
|
3a446dd400 | ||
|
|
876efb11e7 | ||
|
|
c7615aa559 | ||
|
|
6042485367 | ||
|
|
725c340257 | ||
|
|
9abd7ceda2 | ||
|
|
cf81cb942b | ||
|
|
9097ef90b7 | ||
|
|
4f1a4ab072 | ||
|
|
0facc63019 | ||
|
|
95735c3978 | ||
|
|
d5d33d4848 | ||
|
|
84e9401d53 | ||
|
|
84bee3d7d0 | ||
|
|
6ff3c39989 | ||
|
|
5c8239a2a9 | ||
|
|
f9170c5d02 | ||
|
|
6ccb19e274 | ||
|
|
52f876bfbc | ||
|
|
415ff7f483 | ||
|
|
74bc5bfd7c | ||
|
|
7e1e7ba2d8 | ||
|
|
aac5b4673f | ||
|
|
c86b257d38 | ||
|
|
4291d56e0b | ||
|
|
5599603bdb | ||
|
|
e0f69a2294 | ||
|
|
510915a801 | ||
|
|
609d029e20 | ||
|
|
4275ed68a2 | ||
|
|
75d2785d20 | ||
|
|
b77b47bb98 | ||
|
|
5f87f7bbf5 | ||
|
|
1afdb850f3 | ||
|
|
725a6b71dc | ||
|
|
f4bb5b381d | ||
|
|
11d4fc101e | ||
|
|
4e6fb47a3f | ||
|
|
a39bb4310c | ||
|
|
f1ac18933c | ||
|
|
2bae4d2dba | ||
|
|
1797233989 | ||
|
|
3171781d58 | ||
|
|
35ddd8db5e | ||
|
|
c269d5f258 | ||
|
|
627cc1f04b | ||
|
|
53d0bf653a | ||
|
|
a5a9788b20 | ||
|
|
9c04a79c0a | ||
|
|
f3519d895c | ||
|
|
0ac5480034 | ||
|
|
5cd48bbe7d | ||
|
|
8dacafce7f | ||
|
|
8c4b8f2c38 | ||
|
|
fee8bcc50b | ||
|
|
cb9f4a0485 | ||
|
|
79f340a410 | ||
|
|
081e5ef572 | ||
|
|
f5577ad78e | ||
|
|
b2b1c1de9c | ||
|
|
3e6917c8ae | ||
|
|
564b6aa2aa | ||
|
|
eb3eb3c39e | ||
|
|
2579609922 | ||
|
|
946b0229e8 | ||
|
|
0a1eeedc10 | ||
|
|
5a58feefdc | ||
|
|
48b15bd099 | ||
|
|
1c96477686 | ||
|
|
04ebf8d67a | ||
|
|
82d83a9d54 | ||
|
|
66de8aedeb | ||
|
|
154b8e3e0e | ||
|
|
d9f2ee40f7 | ||
|
|
6bcb89cf38 | ||
|
|
3475ecde6f | ||
|
|
728cd5e974 | ||
|
|
0fab14ad9d | ||
|
|
d23febcd6a | ||
|
|
d763926364 | ||
|
|
7a683a4b62 | ||
|
|
44a237b637 | ||
|
|
375304bf13 | ||
|
|
d59aab7fd3 | ||
|
|
60748b1370 | ||
|
|
624cb33534 | ||
|
|
9603eff79a | ||
|
|
e9d6dec2f4 | ||
|
|
0cbfea79fa | ||
|
|
aaae327563 | ||
|
|
5b23f847d6 | ||
|
|
5a55789bc1 | ||
|
|
9c396f6331 | ||
|
|
51e871f9e5 | ||
|
|
47634c294d | ||
|
|
9c1122def0 | ||
|
|
2bd9e84851 | ||
|
|
5c2eedc340 | ||
|
|
3b7d103758 | ||
|
|
8146c43aa3 | ||
|
|
df386927ce | ||
|
|
0c18b2c442 | ||
|
|
1a7b7eba59 | ||
|
|
54fb59b8f3 | ||
|
|
a2f0d335f4 | ||
|
|
2e70c3ceab | ||
|
|
ca1902fb4e | ||
|
|
f11a89031b | ||
|
|
3c22fab679 | ||
|
|
ad46e95df9 | ||
|
|
7d4f2d9aed | ||
|
|
738b3592cd | ||
|
|
cd2af64860 | ||
|
|
04f1e767b2 | ||
|
|
1c7ac2a6ab | ||
|
|
77cf40da87 | ||
|
|
1fe8df85cb | ||
|
|
139f80a291 | ||
|
|
2d066b8715 | ||
|
|
757243993c | ||
|
|
4fb114dcb3 | ||
|
|
612cdac4c3 | ||
|
|
57c66fe813 | ||
|
|
9c02ea9098 | ||
|
|
042b65dfcc | ||
|
|
f6a72ef3c2 | ||
|
|
568cc368ae | ||
|
|
5abe3c2145 | ||
|
|
0e76d21f11 | ||
|
|
5e8693bc42 | ||
|
|
ef78b198cb | ||
|
|
5fdaef3646 | ||
|
|
fa4670c5fe | ||
|
|
c4402a1ce5 | ||
|
|
edd8c613d6 | ||
|
|
0a7f5bf6a5 | ||
|
|
eb3e865f15 | ||
|
|
1baa55c145 | ||
|
|
6ef3837e73 | ||
|
|
e7167e35ed | ||
|
|
2e0325e3bf | ||
|
|
56b3b44342 | ||
|
|
113eea5047 | ||
|
|
dcadaad228 | ||
|
|
7b04e6ac42 | ||
|
|
1533868680 | ||
|
|
f275cc180b | ||
|
|
31d3aef8d6 | ||
|
|
bd467ff765 | ||
|
|
d0a4cce41e | ||
|
|
429f973280 | ||
|
|
6320f739d4 | ||
|
|
1732932c57 | ||
|
|
574b6ab5b1 | ||
|
|
1c737f88fe | ||
|
|
fa8d9b9189 | ||
|
|
512dbedee3 | ||
|
|
db21c2d397 | ||
|
|
1078d178d7 | ||
|
|
a6e780b2f6 | ||
|
|
4e48d0a431 | ||
|
|
c289a88f50 | ||
|
|
4ce495cb2c | ||
|
|
dfea2991c9 | ||
|
|
6f5fc2276a | ||
|
|
d51a9ebb0e | ||
|
|
536d3d76a3 | ||
|
|
5c52dbf661 | ||
|
|
8f797f213e | ||
|
|
ed68f378d7 | ||
|
|
7e8de907f8 | ||
|
|
f3c9252840 | ||
|
|
da9e27f466 | ||
|
|
aa74e28112 | ||
|
|
e569f15631 | ||
|
|
eaace34233 | ||
|
|
7a839e7eb6 | ||
|
|
1b24b6a02b | ||
|
|
2b4a68e276 | ||
|
|
b1e3d79eaa | ||
|
|
2fb2035dbf | ||
|
|
3c51290e0d | ||
|
|
765196d5c3 | ||
|
|
12c2d37e62 | ||
|
|
151a551be9 | ||
|
|
09ce6ff99e | ||
|
|
6ffd7111a6 | ||
|
|
7904a14af1 | ||
|
|
1b79730db8 | ||
|
|
ad8799522c | ||
|
|
f65668cb5f | ||
|
|
393d21d86c | ||
|
|
232c512502 | ||
|
|
8c1e6a82b2 | ||
|
|
2d54efe851 | ||
|
|
c2a4f256c8 | ||
|
|
c91c85532a | ||
|
|
326d4049da | ||
|
|
9b7c4b3884 | ||
|
|
bcde09ae91 | ||
|
|
632651aee2 | ||
|
|
d2b76acb72 | ||
|
|
daf69e8154 | ||
|
|
e5c8abab9e | ||
|
|
2c312e20f1 | ||
|
|
9f1a8be2bf | ||
|
|
bd7d362d3b | ||
|
|
0d0b77ded6 | ||
|
|
83a25d26fc | ||
|
|
9b7df414e6 | ||
|
|
f87016a5fe | ||
|
|
5894ffe82e | ||
|
|
50fa106d87 | ||
|
|
983e1b2303 | ||
|
|
de7f567b9a | ||
|
|
0eabc89840 | ||
|
|
400e901c9c | ||
|
|
57b4865ab3 | ||
|
|
fd41000bc3 | ||
|
|
a70937c926 | ||
|
|
4ec2222fa9 | ||
|
|
fe974f420d | ||
|
|
e65e5f40c9 | ||
|
|
0235eb6c72 | ||
|
|
e943e63174 | ||
|
|
b4ba6e4eaf | ||
|
|
0adcb68092 | ||
|
|
dadef27d7a | ||
|
|
53465a4d2d | ||
|
|
4e837cfa2d | ||
|
|
964e6169cb | ||
|
|
c379191f80 | ||
|
|
912ebffc63 | ||
|
|
b7a11b7bd4 | ||
|
|
95bdb28a05 | ||
|
|
9930ba91c5 | ||
|
|
802c02eb74 | ||
|
|
2d4e3253ca | ||
|
|
da8d45d6c6 | ||
|
|
18b4575e4d | ||
|
|
40fb59e5f7 | ||
|
|
e3ff8c4d28 | ||
|
|
ce59e2dd76 | ||
|
|
32cfc49002 | ||
|
|
d19bc1562b | ||
|
|
ea018a68cc | ||
|
|
1089444807 | ||
|
|
4af8228c34 | ||
|
|
ebea98b8ec | ||
|
|
51683071e8 | ||
|
|
bfa46b2471 | ||
|
|
de62797128 | ||
|
|
05673fb6cf | ||
|
|
350f4709b7 | ||
|
|
b5f7ba502d | ||
|
|
8ba80d2dac | ||
|
|
b11eea07b0 | ||
|
|
35cea9be25 | ||
|
|
3e0e608110 | ||
|
|
e2f8909982 | ||
|
|
ac613b6632 | ||
|
|
5323652cfd | ||
|
|
a58ff1ac63 | ||
|
|
2b60ee96f2 | ||
|
|
da6f07b7c1 | ||
|
|
f0b624d6c9 | ||
|
|
e4c3c02a36 | ||
|
|
ae796b1194 | ||
|
|
c892f38d3c | ||
|
|
d98b6beb4d | ||
|
|
b80abf8dd1 | ||
|
|
acfa762617 | ||
|
|
a44f1912b3 | ||
|
|
bcbfb357be | ||
|
|
b2179de839 | ||
|
|
b1102cedd7 | ||
|
|
571f8c78bd | ||
|
|
93fbd103ba | ||
|
|
a740d563d7 | ||
|
|
8f6e67553f | ||
|
|
0a8be132b9 | ||
|
|
4c932edabc | ||
|
|
5283872e00 | ||
|
|
45c314fbe6 | ||
|
|
119e53967b | ||
|
|
d36a004468 | ||
|
|
8778c39ed0 | ||
|
|
b071f73fef | ||
|
|
afde0a17b7 | ||
|
|
714de9d996 | ||
|
|
6b587fa411 | ||
|
|
7e2f5126bc | ||
|
|
9eab82b717 | ||
|
|
e49ccf49fd | ||
|
|
aac3615d7a | ||
|
|
7de6e925aa | ||
|
|
6fdfe8ea73 | ||
|
|
84bfaad6e6 | ||
|
|
fcac2464e6 | ||
|
|
3eb48cbea7 | ||
|
|
72a48c4992 | ||
|
|
993c1de361 | ||
|
|
90342a4f3a | ||
|
|
0cd632ba84 | ||
|
|
e8779ac329 | ||
|
|
32d844d3b6 | ||
|
|
36725ce153 | ||
|
|
0d537ece10 | ||
|
|
f825dd2897 | ||
|
|
9faa95d558 | ||
|
|
365cbe8d50 | ||
|
|
9a322d52e2 | ||
|
|
89013efbca | ||
|
|
3a90335b5a | ||
|
|
676b64e8a3 | ||
|
|
9007920695 | ||
|
|
2887376646 | ||
|
|
e48d452c63 | ||
|
|
165841ae79 | ||
|
|
76acdb7ae7 | ||
|
|
dfbe4041f5 | ||
|
|
4fd1a6dec3 | ||
|
|
aa394d0e14 | ||
|
|
d5b17d344b | ||
|
|
8111e18dbd | ||
|
|
ba7d12f205 | ||
|
|
ef66ad3b52 | ||
|
|
69e4339af9 | ||
|
|
779904657f | ||
|
|
cb0f6cefa4 | ||
|
|
25ef01b74a | ||
|
|
6db0201fcd | ||
|
|
40e508823f | ||
|
|
3368284b2a | ||
|
|
ece01d89fe | ||
|
|
9c0c4f50ec | ||
|
|
6729637f61 | ||
|
|
18d22aa426 | ||
|
|
a3641526ab | ||
|
|
f50e06a1b6 | ||
|
|
2ae3b45ac1 | ||
|
|
780a43711f | ||
|
|
d682b604de |
BIN
.agent/.DS_Store
vendored
Normal file
BIN
.agent/.DS_Store
vendored
Normal file
Binary file not shown.
366
.agent/workflows/update_clawdbot.md
Normal file
366
.agent/workflows/update_clawdbot.md
Normal file
@@ -0,0 +1,366 @@
|
||||
---
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check divergence status
|
||||
git fetch upstream && git rev-list --left-right --count main...upstream/main
|
||||
|
||||
# Full sync (rebase preferred)
|
||||
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
|
||||
|
||||
# Check for Swift 6.2 issues after sync
|
||||
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Assess Divergence
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git log --oneline --left-right main...upstream/main | head -20
|
||||
```
|
||||
|
||||
This shows:
|
||||
- `<` = your local commits (ahead)
|
||||
- `>` = upstream commits you're missing (behind)
|
||||
|
||||
**Decision point:**
|
||||
- Few local commits, many upstream → **Rebase** (cleaner history)
|
||||
- Many local commits or shared branch → **Merge** (preserves history)
|
||||
|
||||
---
|
||||
|
||||
## Step 2A: Rebase Strategy (Preferred)
|
||||
|
||||
Replays your commits on top of upstream. Results in linear history.
|
||||
|
||||
```bash
|
||||
# Ensure working tree is clean
|
||||
git status
|
||||
|
||||
# Rebase onto upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### Handling Rebase Conflicts
|
||||
|
||||
```bash
|
||||
# When conflicts occur:
|
||||
# 1. Fix conflicts in the listed files
|
||||
# 2. Stage resolved files
|
||||
git add <resolved-files>
|
||||
|
||||
# 3. Continue rebase
|
||||
git rebase --continue
|
||||
|
||||
# If a commit is no longer needed (already in upstream):
|
||||
git rebase --skip
|
||||
|
||||
# To abort and return to original state:
|
||||
git rebase --abort
|
||||
```
|
||||
|
||||
### Common Conflict Patterns
|
||||
|
||||
| File | Resolution |
|
||||
|------|------------|
|
||||
| `package.json` | Take upstream deps, keep local scripts if needed |
|
||||
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
|
||||
| `*.patch` files | Usually take upstream version |
|
||||
| Source files | Merge logic carefully, prefer upstream structure |
|
||||
|
||||
---
|
||||
|
||||
## Step 2B: Merge Strategy (Alternative)
|
||||
|
||||
Preserves all history with a merge commit.
|
||||
|
||||
```bash
|
||||
git merge upstream/main --no-edit
|
||||
```
|
||||
|
||||
Resolve conflicts same as rebase, then:
|
||||
```bash
|
||||
git add <resolved-files>
|
||||
git commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Rebuild Everything
|
||||
|
||||
After sync completes:
|
||||
|
||||
```bash
|
||||
# Install dependencies (regenerates lock if needed)
|
||||
pnpm install
|
||||
|
||||
# Build TypeScript
|
||||
pnpm build
|
||||
|
||||
# Build UI assets
|
||||
pnpm ui:build
|
||||
|
||||
# Run diagnostics
|
||||
pnpm clawdbot doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Rebuild macOS App
|
||||
|
||||
```bash
|
||||
# Full rebuild, sign, and launch
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
# Or just package without restart
|
||||
pnpm mac:package
|
||||
```
|
||||
|
||||
### Install to /Applications
|
||||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "Clawdbot" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/Clawdbot.app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4A: Verify macOS App & Agent
|
||||
|
||||
After rebuilding the macOS app, always verify it works correctly:
|
||||
|
||||
```bash
|
||||
# Check gateway health
|
||||
pnpm clawdbot health
|
||||
|
||||
# Verify no zombie processes
|
||||
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
|
||||
|
||||
# Test agent functionality by sending a verification message
|
||||
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
|
||||
|
||||
# Confirm the message was received on Telegram
|
||||
# (Check your Telegram chat with the bot)
|
||||
```
|
||||
|
||||
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
|
||||
|
||||
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
|
||||
|
||||
### Analyze-Mode Investigation
|
||||
```bash
|
||||
# Gather context with parallel agents
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
### Common Swift 6.2 Fixes
|
||||
|
||||
**FileManager.default Deprecation:**
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with proper initialization
|
||||
# OLD: FileManager.default
|
||||
# NEW: FileManager()
|
||||
```
|
||||
|
||||
**Thread.isMainThread Deprecation:**
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with modern concurrency check
|
||||
# OLD: Thread.isMainThread
|
||||
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
|
||||
```
|
||||
|
||||
### Peekaboo Submodule Fixes
|
||||
```bash
|
||||
# Check Peekaboo for concurrency issues
|
||||
cd src/canvas-host/a2ui
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
|
||||
|
||||
# Fix and rebuild submodule
|
||||
cd /Volumes/Main SSD/Developer/clawdis
|
||||
pnpm canvas:a2ui:bundle
|
||||
```
|
||||
|
||||
### macOS App Concurrency Fixes
|
||||
```bash
|
||||
# Check macOS app for issues
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
|
||||
|
||||
# Clean and rebuild after fixes
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Model Configuration Updates
|
||||
If upstream introduced new model configurations:
|
||||
```bash
|
||||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify & Push
|
||||
|
||||
```bash
|
||||
# Verify everything works
|
||||
pnpm clawdbot health
|
||||
pnpm test
|
||||
|
||||
# Push (force required after rebase)
|
||||
git push origin main --force-with-lease
|
||||
|
||||
# Or regular push after merge
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails After Sync
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
rm -rf node_modules dist
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Type Errors (Bun/Node Incompatibility)
|
||||
|
||||
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
|
||||
|
||||
### macOS App Crashes on Launch
|
||||
|
||||
Usually resource bundle mismatch. Full rebuild required:
|
||||
```bash
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Patch Failures
|
||||
|
||||
```bash
|
||||
# Check patch status
|
||||
pnpm install 2>&1 | grep -i patch
|
||||
|
||||
# If patches fail, they may need updating for new dep versions
|
||||
# Check patches/ directory against package.json patchedDependencies
|
||||
```
|
||||
|
||||
### Swift 6.2 / macOS 26 SDK Build Failures
|
||||
|
||||
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
|
||||
|
||||
**Search-Mode Investigation:**
|
||||
```bash
|
||||
# Exhaustive search for deprecated APIs
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
**Quick Fix Commands:**
|
||||
```bash
|
||||
# Find all affected files
|
||||
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
|
||||
|
||||
# Replace FileManager.default with FileManager()
|
||||
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
|
||||
|
||||
# For Thread.isMainThread, need manual review of each usage
|
||||
grep -rn "Thread\.isMainThread" --include="*.swift" .
|
||||
```
|
||||
|
||||
**Rebuild After Fixes:**
|
||||
```bash
|
||||
# Clean all build artifacts
|
||||
rm -rf apps/macos/.build apps/macos/.swiftpm
|
||||
rm -rf src/canvas-host/a2ui/.build
|
||||
|
||||
# Rebuild Peekaboo bundle
|
||||
pnpm canvas:a2ui:bundle
|
||||
|
||||
# Full macOS rebuild
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automation Script
|
||||
|
||||
Save as `scripts/sync-upstream.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Fetching upstream..."
|
||||
git fetch upstream
|
||||
|
||||
echo "==> Current divergence:"
|
||||
git rev-list --left-right --count main...upstream/main
|
||||
|
||||
echo "==> Rebasing onto upstream/main..."
|
||||
git rebase upstream/main
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
echo "==> Running doctor..."
|
||||
pnpm clawdbot doctor
|
||||
|
||||
echo "==> Rebuilding macOS app..."
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
echo "==> Verifying gateway health..."
|
||||
pnpm clawdbot health
|
||||
|
||||
echo "==> Checking for Swift 6.2 compatibility issues..."
|
||||
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
|
||||
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
|
||||
echo " Run manual fixes or use analyze-mode investigation"
|
||||
else
|
||||
echo "✅ No obvious Swift deprecation issues found"
|
||||
fi
|
||||
|
||||
echo "==> Testing agent functionality..."
|
||||
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
|
||||
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
|
||||
|
||||
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
|
||||
```
|
||||
26
.detect-secrets.cfg
Normal file
26
.detect-secrets.cfg
Normal file
@@ -0,0 +1,26 @@
|
||||
# detect-secrets exclusion patterns (regex)
|
||||
#
|
||||
# Note: detect-secrets does not read this file by default. If you want these
|
||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
||||
# / --exclude-lines) or into a baseline's filters_used.
|
||||
|
||||
[exclude-files]
|
||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||
pattern = (^|/)pnpm-lock\.yaml$
|
||||
|
||||
[exclude-lines]
|
||||
# Fastlane checks for private key marker; not a real key.
|
||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
||||
# UI label string for Anthropic auth mode.
|
||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
||||
# CodingKeys mapping uses apiKey literal.
|
||||
pattern = case apikey = "apiKey"
|
||||
# Schema labels referencing password fields (not actual secrets).
|
||||
pattern = "gateway\.remote\.password"
|
||||
pattern = "gateway\.auth\.password"
|
||||
# Schema label for talk API key (label text only).
|
||||
pattern = "talk\.apiKey"
|
||||
# checking for typeof is not something we care about.
|
||||
pattern = === "string"
|
||||
# specific optional-chaining password check that didn't match the line above.
|
||||
pattern = typeof remote\?\.password === "string"
|
||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@@ -74,9 +74,9 @@ jobs:
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
@@ -141,6 +141,31 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install detect-secrets==1.5.0
|
||||
|
||||
- name: Detect secrets
|
||||
run: |
|
||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
defaults:
|
||||
|
||||
2
.github/workflows/install-smoke.yml
vendored
2
.github/workflows/install-smoke.yml
vendored
@@ -29,4 +29,6 @@ jobs:
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
@@ -31,6 +32,11 @@ apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
apps/ios/Clawdbot.xcodeproj/
|
||||
apps/ios/Clawdbot.xcodeproj/**
|
||||
apps/macos/.build/**
|
||||
**/*.bun-build
|
||||
apps/ios/*.xcfilelist
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
@@ -43,6 +49,8 @@ apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
apps/ios/fastlane/report.xml
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@@ -55,3 +63,9 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
||||
|
||||
5
.oxfmtrc.jsonc
Normal file
5
.oxfmtrc.jsonc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"indentWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
12
.oxlintrc.json
Normal file
12
.oxlintrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"unicorn",
|
||||
"typescript",
|
||||
"oxc"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
||||
}
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
src/canvas-host/a2ui/a2ui.bundle.js
|
||||
518
.secrets.baseline
Normal file
518
.secrets.baseline
Normal file
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{
|
||||
"name": "ArtifactoryDetector"
|
||||
},
|
||||
{
|
||||
"name": "AWSKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "AzureStorageKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "Base64HighEntropyString",
|
||||
"limit": 4.5
|
||||
},
|
||||
{
|
||||
"name": "BasicAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "CloudantDetector"
|
||||
},
|
||||
{
|
||||
"name": "DiscordBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitHubTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "GitLabTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "HexHighEntropyString",
|
||||
"limit": 3.0
|
||||
},
|
||||
{
|
||||
"name": "IbmCloudIamDetector"
|
||||
},
|
||||
{
|
||||
"name": "IbmCosHmacDetector"
|
||||
},
|
||||
{
|
||||
"name": "IPPublicDetector"
|
||||
},
|
||||
{
|
||||
"name": "JwtTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "KeywordDetector",
|
||||
"keyword_exclude": ""
|
||||
},
|
||||
{
|
||||
"name": "MailchimpDetector"
|
||||
},
|
||||
{
|
||||
"name": "NpmDetector"
|
||||
},
|
||||
{
|
||||
"name": "OpenAIDetector"
|
||||
},
|
||||
{
|
||||
"name": "PrivateKeyDetector"
|
||||
},
|
||||
{
|
||||
"name": "PypiTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "SendGridDetector"
|
||||
},
|
||||
{
|
||||
"name": "SlackDetector"
|
||||
},
|
||||
{
|
||||
"name": "SoftlayerDetector"
|
||||
},
|
||||
{
|
||||
"name": "SquareOAuthDetector"
|
||||
},
|
||||
{
|
||||
"name": "StripeDetector"
|
||||
},
|
||||
{
|
||||
"name": "TelegramBotTokenDetector"
|
||||
},
|
||||
{
|
||||
"name": "TwilioKeyDetector"
|
||||
}
|
||||
],
|
||||
"filters_used": [
|
||||
{
|
||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
||||
"filename": ".secrets.baseline"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||
"min_level": 2
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
||||
"pattern": [
|
||||
"(^|/)pnpm-lock\\.yaml$"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "detect_secrets.filters.regex.should_exclude_line",
|
||||
"pattern": [
|
||||
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
|
||||
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
|
||||
"case apikey = \"apiKey\"",
|
||||
"\"gateway\\.remote\\.password\"",
|
||||
"\"gateway\\.auth\\.password\"",
|
||||
"\"talk\\.apiKey\"",
|
||||
"=== \"string\"",
|
||||
"typeof remote\\?\\.password === \"string\""
|
||||
]
|
||||
}
|
||||
],
|
||||
"results": {
|
||||
".env.example": [
|
||||
{
|
||||
"type": "Twilio API Key",
|
||||
"filename": ".env.example",
|
||||
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
|
||||
"is_verified": false,
|
||||
"line_number": 2
|
||||
}
|
||||
],
|
||||
"appcast.xml": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
|
||||
"is_verified": false,
|
||||
"line_number": 19
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
|
||||
"is_verified": false,
|
||||
"line_number": 35
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "appcast.xml",
|
||||
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
||||
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
|
||||
"is_verified": false,
|
||||
"line_number": 26
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
||||
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
|
||||
"is_verified": false,
|
||||
"line_number": 42
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 83
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 27
|
||||
}
|
||||
],
|
||||
"docs/configuration.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 268
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
||||
"is_verified": false,
|
||||
"line_number": 465
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
||||
"is_verified": false,
|
||||
"line_number": 718
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
||||
"is_verified": false,
|
||||
"line_number": 760
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 859
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/configuration.md",
|
||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
||||
"is_verified": false,
|
||||
"line_number": 982
|
||||
}
|
||||
],
|
||||
"docs/faq.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/faq.md",
|
||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
||||
"is_verified": false,
|
||||
"line_number": 593
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/faq.md",
|
||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
||||
"is_verified": false,
|
||||
"line_number": 650
|
||||
}
|
||||
],
|
||||
"docs/skills-config.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/skills-config.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
}
|
||||
],
|
||||
"docs/skills.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/skills.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 97
|
||||
}
|
||||
],
|
||||
"docs/tailscale.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tailscale.md",
|
||||
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
}
|
||||
],
|
||||
"docs/talk.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/talk.md",
|
||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
||||
"is_verified": false,
|
||||
"line_number": 50
|
||||
}
|
||||
],
|
||||
"docs/telegram.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/telegram.md",
|
||||
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
|
||||
"is_verified": false,
|
||||
"line_number": 57
|
||||
}
|
||||
],
|
||||
"skills/local-places/SERVER_README.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/local-places/SERVER_README.md",
|
||||
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
}
|
||||
],
|
||||
"skills/openai-whisper-api/SKILL.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/openai-whisper-api/SKILL.md",
|
||||
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
|
||||
"is_verified": false,
|
||||
"line_number": 39
|
||||
}
|
||||
],
|
||||
"skills/trello/SKILL.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "skills/trello/SKILL.md",
|
||||
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
|
||||
"is_verified": false,
|
||||
"line_number": 18
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.test.ts",
|
||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.test.ts",
|
||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
||||
"is_verified": false,
|
||||
"line_number": 90
|
||||
}
|
||||
],
|
||||
"src/agents/skills.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 158
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
|
||||
"is_verified": false,
|
||||
"line_number": 265
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
|
||||
"is_verified": false,
|
||||
"line_number": 462
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/skills.test.ts",
|
||||
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
|
||||
"is_verified": false,
|
||||
"line_number": 490
|
||||
}
|
||||
],
|
||||
"src/browser/target-id.test.ts": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "src/browser/target-id.test.ts",
|
||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
||||
"is_verified": false,
|
||||
"line_number": 16
|
||||
}
|
||||
],
|
||||
"src/commands/antigravity-oauth.ts": [
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/commands/antigravity-oauth.ts",
|
||||
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
|
||||
"is_verified": false,
|
||||
"line_number": 17
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/commands/antigravity-oauth.ts",
|
||||
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
|
||||
"is_verified": false,
|
||||
"line_number": 20
|
||||
}
|
||||
],
|
||||
"src/commands/onboard-auth.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/commands/onboard-auth.ts",
|
||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
||||
"is_verified": false,
|
||||
"line_number": 50
|
||||
}
|
||||
],
|
||||
"src/config/config.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/config.test.ts",
|
||||
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
|
||||
"is_verified": false,
|
||||
"line_number": 520
|
||||
}
|
||||
],
|
||||
"src/gateway/server.auth.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/gateway/server.auth.test.ts",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 89
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/gateway/server.auth.test.ts",
|
||||
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
|
||||
"is_verified": false,
|
||||
"line_number": 109
|
||||
}
|
||||
],
|
||||
"src/infra/env.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/env.test.ts",
|
||||
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
|
||||
"is_verified": false,
|
||||
"line_number": 10
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/env.test.ts",
|
||||
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
}
|
||||
],
|
||||
"src/infra/shell-env.test.ts": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
|
||||
"is_verified": false,
|
||||
"line_number": 35
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
|
||||
"is_verified": false,
|
||||
"line_number": 56
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
|
||||
"is_verified": false,
|
||||
"line_number": 73
|
||||
},
|
||||
{
|
||||
"type": "Base64 High Entropy String",
|
||||
"filename": "src/infra/shell-env.test.ts",
|
||||
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
|
||||
"is_verified": false,
|
||||
"line_number": 75
|
||||
}
|
||||
],
|
||||
"src/web/qr-image.test.ts": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "src/web/qr-image.test.ts",
|
||||
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
|
||||
"is_verified": false,
|
||||
"line_number": 12
|
||||
}
|
||||
],
|
||||
"vendor/a2ui/README.md": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "vendor/a2ui/README.md",
|
||||
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
|
||||
"is_verified": false,
|
||||
"line_number": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-01-05T13:01:00Z"
|
||||
}
|
||||
77
AGENTS.md
77
AGENTS.md
@@ -1,12 +1,18 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
@@ -17,6 +23,16 @@
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
||||
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
|
||||
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
||||
- Restart: stop old gateway and run:
|
||||
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
||||
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
@@ -24,22 +40,29 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Release Channels (Naming)
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/testing.md`.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
@@ -52,13 +75,20 @@
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
@@ -68,18 +98,23 @@
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
@@ -96,31 +131,29 @@
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- Lint/format churn:
|
||||
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||
- Only ask when changes are semantic (logic/data/behavior).
|
||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
722
CHANGELOG.md
722
CHANGELOG.md
@@ -1,116 +1,668 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.13-1
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Changes
|
||||
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
|
||||
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
|
||||
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
|
||||
### Fixes
|
||||
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
||||
- CLI: move auth probe errors below the table to reduce wrapping.
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
|
||||
## 2026.1.12-4
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
|
||||
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
|
||||
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
|
||||
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
|
||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
|
||||
### Fixes
|
||||
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
|
||||
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
|
||||
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
|
||||
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
|
||||
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
|
||||
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
|
||||
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
|
||||
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
|
||||
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
|
||||
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
|
||||
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
|
||||
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
|
||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
|
||||
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
|
||||
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
|
||||
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
|
||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
|
||||
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
|
||||
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
|
||||
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
|
||||
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
|
||||
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
|
||||
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
|
||||
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
|
||||
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
|
||||
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
|
||||
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
|
||||
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||
|
||||
## 2026.1.12-3
|
||||
## 2026.1.21-2
|
||||
|
||||
### Fixes
|
||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Sandbox: drop legacy `memory` tool-policy shorthand; require explicit `group:memory`.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Telegram: tolerate mocked bots missing native-command APIs (`setMyCommands`, `command`) during tests.
|
||||
- Auto-reply: fix streaming block reply media handling (no redeclared/use-before-declare vars).
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
|
||||
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
|
||||
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
|
||||
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
|
||||
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
|
||||
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
|
||||
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
|
||||
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
|
||||
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
|
||||
- Discord: expose `/skill` globally. (#1287)
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
|
||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
|
||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
|
||||
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
|
||||
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Build: update workspace + core/plugin deps.
|
||||
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
|
||||
|
||||
### Fixes
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
||||
- Diagnostics: gate heartbeat/webhook logging. (#1244)
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
|
||||
- Gateway: clarify connect/validation errors for gateway params. (#1347)
|
||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
|
||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
|
||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
|
||||
- CLI: skip runner rebuilds when dist is fresh. (#1231)
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101)
|
||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
|
||||
- UI: preserve ordered list numbering in chat markdown. (#1341)
|
||||
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201)
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`.
|
||||
- Memory: fall back to non-batch embeddings after repeated batch failures.
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Exec approvals: enforce allowlist when ask is off.
|
||||
- Exec approvals: prefer raw command for node approvals/events.
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
|
||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282)
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
- Anthropic: default API prompt caching to 1h with configurable TTL override.
|
||||
- Anthropic: ignore TTL for OAuth.
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
|
||||
- Auth profiles: user pins stay locked. (#1138)
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
|
||||
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
- Windows: install gateway scheduled task as the current user.
|
||||
- Windows: show friendly guidance instead of failing on access denied.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
|
||||
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
### Highlights
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
||||
- Messages: refresh live directory cache results when resolving targets.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
|
||||
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Tools: include provider/session context in elevated exec denial errors.
|
||||
- Tools: normalize exec tool alias naming in tool error logs.
|
||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
||||
- Logging: prefix nested agent output with session/run/channel context.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Sessions: preserve overrides on `/new` reset.
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
- Messages: honor message tool channel when deduping sends.
|
||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
||||
- Security: redact sensitive text in gateway WS logs.
|
||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- WhatsApp: include `linked` field in `describeAccount`.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: hide the image tool when the primary model already supports images.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- iMessage: avoid RPC restart loops.
|
||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
|
||||
- CLI: auto-update global installs when installed via a package manager.
|
||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||
- Security: bump `tar` to 7.5.3.
|
||||
- Models: align ZAI thinking toggles.
|
||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
- Telegram: default reaction notifications to own.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
|
||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
|
||||
### Fixes
|
||||
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
|
||||
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
|
||||
- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
|
||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||
|
||||
### Changes
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||
- Security: add `SECURITY.md` reporting policy.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
|
||||
### Fixes
|
||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Changes
|
||||
- Usage: add MiniMax coding plan usage tracking.
|
||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
||||
|
||||
### Fixes
|
||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
|
||||
#### Agents / Auth / Tools / Sandbox
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
||||
|
||||
## 2026.1.13
|
||||
|
||||
### Fixes
|
||||
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
|
||||
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
|
||||
|
||||
## 2026.1.12-2
|
||||
|
||||
### Changes
|
||||
- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`.
|
||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
||||
- Sandbox: support tool-policy groups in `tools.sandbox.tools` (e.g. `group:memory`, `group:fs`) to reduce config churn.
|
||||
|
||||
### Fixes
|
||||
- Models/MiniMax: strip malformed tool invocation XML (`<invoke>...</invoke>` and `</minimax:tool_call>`) from assistant text to prevent tool call leaks into user messages. (#809 — thanks @latitudeki5223)
|
||||
- Tools/Models: MiniMax vision now uses the Coding Plan VLM endpoint (`/v1/coding_plan/vlm`) so the `image` tool works with MiniMax keys (also accepts `@/path/to/file.png`-style inputs).
|
||||
- Gateway/macOS: reduce noisy loopback WS "closed before connect" logs during tests.
|
||||
- Auto-reply: resolve ambiguous `/model` fuzzy matches by picking the best candidate instead of erroring.
|
||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
||||
|
||||
## 2026.1.12-1
|
||||
|
||||
### Changes
|
||||
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
|
||||
- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly.
|
||||
### Fixes
|
||||
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
|
||||
|
||||
## 2026.1.12 (Unreleased)
|
||||
## 2026.1.12
|
||||
|
||||
### Highlights
|
||||
- Memory: add vector search for agent memories (Markdown-only scope) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
|
||||
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
||||
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
|
||||
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
||||
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
|
||||
|
||||
### Changes
|
||||
- Agents: strengthen memory recall guidance (memory_search mandatory for past work/preferences; system prompt injects conditional recall section; memory_get now described as safe snippet fetch).
|
||||
- Browser: add `scrollintoview` action to scroll refs into view before click/type.
|
||||
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
|
||||
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
|
||||
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
|
||||
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
|
||||
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
|
||||
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
|
||||
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
|
||||
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
|
||||
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
|
||||
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
|
||||
### New & Improved
|
||||
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
|
||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
|
||||
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
|
||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
|
||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
|
||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
|
||||
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Installer
|
||||
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Fixes
|
||||
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
|
||||
- Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs).
|
||||
- Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model.
|
||||
- Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c.
|
||||
- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends.
|
||||
- Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ.
|
||||
- Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly.
|
||||
- Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build.
|
||||
- Google: apply patched pi-ai `google-gemini-cli` function call handling (strips ids) after upgrading to pi-ai 0.43.0.
|
||||
- Tools: allow Gemini/Claude-style aliases (`file_path`, `old_string`, `new_string`) in read/write/edit schemas while still enforcing required params at runtime to avoid validation loops.
|
||||
- Auto-reply: elevated/reasoning toggles now enqueue system events so the model sees the mode change immediately.
|
||||
- Tools: keep `image` available in sandbox and fail over when image models return empty output (fixes “(no text returned)”).
|
||||
- Discord: add per-channel `autoThread` to auto-create threads for top-level messages. (#800) — thanks @davidguttman.
|
||||
- Discord: fix autoThread routing so replies stay in the created thread and avoid reply references. (#807) — thanks @davidguttman.
|
||||
- Onboarding: TUI defaults to `deliver: false` to avoid cross-provider auto-delivery leaks; onboarding spawns the TUI with explicit `deliver: false`. (#791 — thanks @roshanasingh4)
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
|
||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
|
||||
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
|
||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
|
||||
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
|
||||
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
|
||||
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
|
||||
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
|
||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
|
||||
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
|
||||
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
|
||||
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
|
||||
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
||||
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
|
||||
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
|
||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
|
||||
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
|
||||
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
|
||||
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
||||
|
||||
### Maintenance
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
- Testing: update Vitest + browser-playwright to 4.0.17.
|
||||
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
|
||||
|
||||
## 2026.1.11
|
||||
|
||||
@@ -351,7 +903,7 @@
|
||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||
|
||||
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||
|
||||
## 2026.1.8
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV CLAWDBOT_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
|
||||
1
Peekaboo
1
Peekaboo
Submodule Peekaboo deleted from c1243a7978
149
README.md
149
README.md
@@ -11,18 +11,19 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
@@ -54,7 +55,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
@@ -64,12 +65,21 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
@@ -97,17 +107,17 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
@@ -127,9 +137,9 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||
### Channels
|
||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||
@@ -145,10 +155,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
- [Provider routing](https://docs.clawd.bot/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/troubleshooting).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||
@@ -159,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -204,7 +214,7 @@ Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web sur
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and provider connections by default.
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
@@ -247,9 +257,9 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
@@ -257,12 +267,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
|
||||
@@ -315,52 +320,56 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
|
||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/providers/whatsapp)
|
||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.clawd.bot/providers/telegram)
|
||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.clawd.bot/providers/slack)
|
||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawd.bot/providers/discord)
|
||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawd.bot/providers/signal)
|
||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawd.bot/providers/imessage)
|
||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
@@ -395,7 +404,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
@@ -462,26 +471,36 @@ by Peter Steinberger and the community.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
|
||||
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
|
||||
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a>
|
||||
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
|
||||
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||
<a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a>
|
||||
<a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a>
|
||||
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a>
|
||||
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
||||
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a>
|
||||
<a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
15
SECURITY.md
Normal file
15
SECURITY.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
||||
|
||||
## Reporting
|
||||
|
||||
- Email: `steipete@gmail.com`
|
||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||
|
||||
- `https://docs.clawd.bot/gateway/security`
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -101,8 +101,8 @@ Environment variables:
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
|
||||
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -34,8 +34,7 @@ extension AttributedString {
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
|
||||
@@ -13,8 +13,7 @@ public actor TranscriptsStore {
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
minCommandLength: Int = 1) {
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
@@ -57,6 +56,12 @@ public enum WakeWordGate {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private struct MatchCandidate {
|
||||
let index: Int
|
||||
let triggerEnd: TimeInterval
|
||||
let gap: TimeInterval
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
@@ -68,7 +73,7 @@ public enum WakeWordGate {
|
||||
let tokens = normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
var best: MatchCandidate?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
@@ -84,7 +89,7 @@ public enum WakeWordGate {
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
descriptor(for: ServiceStatus.self)
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
statusDesc
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
"KeepAlive": true
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
|
||||
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await dispatchSwabble(parsed: parsed, path: path)
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
||||
switch sub {
|
||||
case "mic":
|
||||
try await dispatchMic(parsed: parsed, path: path)
|
||||
case "service":
|
||||
try await dispatchService(path: path)
|
||||
default:
|
||||
let handlers = swabbleHandlers(parsed: parsed)
|
||||
guard let handler = handlers[sub] else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
try await handler()
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
||||
[
|
||||
"serve": {
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"transcribe": {
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"test-hook": {
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"doctor": {
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"setup": {
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"health": {
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"tail-log": {
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
},
|
||||
"start": {
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"stop": {
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"restart": {
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
},
|
||||
"status": {
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
||||
switch micSub {
|
||||
case "list":
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "set":
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatchService(path: [String]) async throws {
|
||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
}
|
||||
|
||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
||||
guard path.count > index else {
|
||||
throw CommanderProgramError.missingSubcommand(command: command)
|
||||
}
|
||||
return path[index]
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
||||
else
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
fi
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
||||
else
|
||||
CONFIG="$ROOT/.swiftlint.yml"
|
||||
fi
|
||||
CONFIG="${ROOT}/.swiftlint.yml"
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
|
||||
308
appcast.xml
308
appcast.xml
@@ -3,54 +3,316 @@
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||
<title>2026.1.22</title>
|
||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:version>7530</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
||||
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
||||
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
||||
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
||||
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
||||
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
||||
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
||||
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
||||
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
||||
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
||||
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
||||
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
||||
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
||||
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
||||
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
||||
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
||||
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
||||
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
||||
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
||||
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
||||
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
||||
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
||||
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
||||
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
||||
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
||||
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
||||
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
||||
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
||||
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
||||
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-2</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5210</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
|
||||
<sparkle:version>7374</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
|
||||
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
|
||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-1</title>
|
||||
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</pubDate>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5207</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
|
||||
<sparkle:version>7116</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||
<li>Build: update workspace + core/plugin deps.</li>
|
||||
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun installs.</li>
|
||||
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||
</ul>
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,6 +1,6 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
@@ -30,7 +30,7 @@ pnpm clawdbot gateway --port 18789 --verbose
|
||||
|
||||
2) In the Android app:
|
||||
- Open **Settings**
|
||||
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
||||
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
```bash
|
||||
@@ -38,7 +38,7 @@ clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/android/connect.md`.
|
||||
More details: `docs/platforms/android.md`.
|
||||
|
||||
## Permissions
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
versionCode = 202601230
|
||||
versionName = "2026.1.23"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -103,6 +103,7 @@ dependencies {
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
@@ -112,14 +113,14 @@ dependencies {
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.3")
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.gateway.GatewayEndpoint
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.CanvasController
|
||||
@@ -18,7 +18,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
val sms: SmsManager = runtime.sms
|
||||
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
@@ -49,6 +50,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
@@ -98,6 +100,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
@@ -118,11 +124,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
runtime.refreshBridgeHello()
|
||||
fun refreshGatewayConnection() {
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
@@ -138,7 +144,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@ import com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.bridge.BridgeDiscovery
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.bridge.BridgePairingClient
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.gateway.DeviceAuthStore
|
||||
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||
import com.clawdbot.android.gateway.GatewayClientInfo
|
||||
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||
import com.clawdbot.android.gateway.GatewayDiscovery
|
||||
import com.clawdbot.android.gateway.GatewayEndpoint
|
||||
import com.clawdbot.android.gateway.GatewaySession
|
||||
import com.clawdbot.android.gateway.GatewayTlsParams
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.LocationCaptureManager
|
||||
import com.clawdbot.android.BuildConfig
|
||||
@@ -59,6 +63,7 @@ class NodeRuntime(context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -73,12 +78,12 @@ class NodeRuntime(context: Context) {
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
onCommand = { command ->
|
||||
session.sendEvent(
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
@@ -102,10 +107,12 @@ class NodeRuntime(context: Context) {
|
||||
val talkIsSpeaking: StateFlow<Boolean>
|
||||
get() = talkMode.isSpeaking
|
||||
|
||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||
private val discovery = GatewayDiscovery(appContext, scope = scope)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
|
||||
private val identityStore = DeviceIdentityStore(appContext)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
@@ -138,43 +145,116 @@ class NodeRuntime(context: Context) {
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
private var operatorConnected = false
|
||||
private var nodeConnected = false
|
||||
private var operatorStatusText: String = "Offline"
|
||||
private var nodeStatusText: String = "Offline"
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
private val operatorSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
onConnected = { name, remote ->
|
||||
_statusText.value = "Connected"
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
operatorStatusText = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
}
|
||||
val mainKey = resolveMainSessionKey()
|
||||
talkMode.setMainSessionKey(mainKey)
|
||||
chat.applyMainSessionKey(mainKey)
|
||||
chat.onDisconnected(message)
|
||||
updateStatus()
|
||||
},
|
||||
onEvent = { event, payloadJson ->
|
||||
handleBridgeEvent(event, payloadJson)
|
||||
},
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
nodeConnected = true
|
||||
nodeStatusText = "Connected"
|
||||
updateStatus()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
nodeConnected = false
|
||||
nodeStatusText = message
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat: ChatController =
|
||||
ChatController(
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
)
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleSessionDisconnected(message: String) {
|
||||
_statusText.value = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_mainSessionKey.value = "main"
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = candidate?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
_statusText.value =
|
||||
when {
|
||||
operatorConnected && nodeConnected -> "Connected"
|
||||
operatorConnected && !nodeConnected -> "Connected (node offline)"
|
||||
!operatorConnected && nodeConnected -> "Connected (operator offline)"
|
||||
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
|
||||
else -> nodeStatusText
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
@@ -203,6 +283,7 @@ class NodeRuntime(context: Context) {
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
@@ -263,24 +344,21 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
bridges.collect { list ->
|
||||
gateways.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
||||
// Persist the last discovered gateway (best-effort UX parity with iOS).
|
||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
val token = prefs.loadBridgeToken()
|
||||
if (token.isNullOrBlank()) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
didAutoConnect = true
|
||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
@@ -346,6 +424,10 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
@@ -404,93 +486,87 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
private fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
return BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = token,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSessionHello(token: String?): BridgeSession.Hello {
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
return BridgeSession.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = token,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
scope.launch {
|
||||
if (!_isConnected.value) return@launch
|
||||
val token = prefs.loadBridgeToken()
|
||||
if (token.isNullOrBlank()) return@launch
|
||||
session.updateHello(buildSessionHello(token))
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello = buildPairingHello(token = null),
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
}
|
||||
private fun resolveModelIdentifier(): String? {
|
||||
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
|
||||
_statusText.value = "Failed: $errorMessage"
|
||||
return@launch
|
||||
}
|
||||
private fun buildUserAgent(): String {
|
||||
val version = resolvedVersionName()
|
||||
val release = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val releaseLabel = if (release.isEmpty()) "unknown" else release
|
||||
return "ClawdbotAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
val authToken = requireNotNull(resolved.token).trim()
|
||||
prefs.saveBridgeToken(authToken)
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
)
|
||||
}
|
||||
private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
|
||||
return GatewayClientInfo(
|
||||
id = clientId,
|
||||
displayName = displayName.value,
|
||||
version = resolvedVersionName(),
|
||||
platform = "android",
|
||||
mode = clientMode,
|
||||
instanceId = instanceId.value,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = resolveModelIdentifier(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNodeConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = emptyList(),
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = emptyList(),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "clawdbot-control-ui", clientMode = "ui"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||
operatorSession.reconnect()
|
||||
nodeSession.reconnect()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
@@ -528,11 +604,49 @@ class NodeRuntime(context: Context) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
return
|
||||
}
|
||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
session.disconnect()
|
||||
connectedEndpoint = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
}
|
||||
|
||||
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (manual) {
|
||||
if (!manualTls.value) return null
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (hinted) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
@@ -559,7 +673,7 @@ class NodeRuntime(context: Context) {
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val message =
|
||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
@@ -571,11 +685,11 @@ class NodeRuntime(context: Context) {
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = isConnected.value
|
||||
val connected = nodeConnected
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
session.sendEvent(
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
@@ -590,7 +704,7 @@ class NodeRuntime(context: Context) {
|
||||
error = e.message ?: "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "bridge not connected"
|
||||
error = "gateway not connected"
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -607,8 +721,9 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
fun loadChat(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
||||
chat.load(key)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
@@ -635,7 +750,7 @@ class NodeRuntime(context: Context) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
if (event == "voicewake.changed") {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
@@ -649,8 +764,8 @@ class NodeRuntime(context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
talkMode.handleBridgeEvent(event, payloadJson)
|
||||
chat.handleBridgeEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
@@ -671,7 +786,7 @@ class NodeRuntime(context: Context) {
|
||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||
val params = """{"triggers":[$jsonList]}"""
|
||||
try {
|
||||
session.request("voicewake.set", params)
|
||||
operatorSession.request("voicewake.set", params)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
@@ -681,7 +796,7 @@ class NodeRuntime(context: Context) {
|
||||
private suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.request("voicewake.get", "{}")
|
||||
val res = operatorSession.request("voicewake.get", "{}")
|
||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
@@ -694,14 +809,14 @@ class NodeRuntime(context: Context) {
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.request("config.get", "{}")
|
||||
val res = operatorSession.request("config.get", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val ui = config?.get("ui").asObjectOrNull()
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
_mainSessionKey.value = mainKey
|
||||
applyMainSessionKey(mainKey)
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
@@ -710,7 +825,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (
|
||||
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
||||
@@ -718,14 +833,14 @@ class NodeRuntime(context: Context) {
|
||||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
@@ -733,7 +848,7 @@ class NodeRuntime(context: Context) {
|
||||
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
||||
locationMode.value == LocationMode.Off
|
||||
) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
@@ -743,18 +858,18 @@ class NodeRuntime(context: Context) {
|
||||
ClawdbotCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||
ClawdbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
|
||||
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdbotCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
@@ -762,12 +877,12 @@ class NodeRuntime(context: Context) {
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
@@ -779,51 +894,51 @@ class NodeRuntime(context: Context) {
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(a2uiResetJS)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
decodeA2uiMessages(command, paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||
return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||
}
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdbotCameraCommand.Snap.rawValue -> {
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
@@ -834,10 +949,10 @@ class NodeRuntime(context: Context) {
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
ClawdbotCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
@@ -850,10 +965,10 @@ class NodeRuntime(context: Context) {
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
@@ -861,19 +976,19 @@ class NodeRuntime(context: Context) {
|
||||
ClawdbotLocationCommand.Get.rawValue -> {
|
||||
val mode = locationMode.value
|
||||
if (!isForeground.value && mode != LocationMode.Always) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
)
|
||||
}
|
||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||
)
|
||||
@@ -900,15 +1015,15 @@ class NodeRuntime(context: Context) {
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = accuracy == "precise",
|
||||
)
|
||||
BridgeSession.InvokeResult.ok(payload.payloadJson)
|
||||
GatewaySession.InvokeResult.ok(payload.payloadJson)
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
BridgeSession.InvokeResult.error(
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_TIMEOUT",
|
||||
message = "LOCATION_TIMEOUT: no fix in time",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
}
|
||||
}
|
||||
ClawdbotScreenCommand.Record.rawValue -> {
|
||||
@@ -920,9 +1035,9 @@ class NodeRuntime(context: Context) {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
_screenRecordActive.value = false
|
||||
}
|
||||
@@ -930,16 +1045,16 @@ class NodeRuntime(context: Context) {
|
||||
ClawdbotSmsCommand.Send.rawValue -> {
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEND_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||
BridgeSession.InvokeResult.error(code = code, message = error)
|
||||
GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
BridgeSession.InvokeResult.error(
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
@@ -995,7 +1110,9 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||
|
||||
@@ -58,17 +58,30 @@ class SecurePrefs(context: Context) {
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
||||
private val _manualEnabled =
|
||||
MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
||||
private val _manualHost =
|
||||
MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", ""))
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
||||
private val _manualPort =
|
||||
MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _manualTls =
|
||||
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||
MutableStateFlow(
|
||||
readStringWithMigration(
|
||||
"gateway.lastDiscoveredStableID",
|
||||
"bridge.lastDiscoveredStableId",
|
||||
"",
|
||||
),
|
||||
)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
@@ -86,7 +99,7 @@ class SecurePrefs(context: Context) {
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
@@ -117,34 +130,75 @@ class SecurePrefs(context: Context) {
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
prefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
prefs.edit { putInt("gateway.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadBridgeToken(): String? {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
fun loadGatewayToken(): String? {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
if (!stored.isNullOrEmpty()) return stored
|
||||
val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim()
|
||||
return legacy?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
prefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "gateway.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun saveBridgeToken(token: String) {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
fun putString(key: String, value: String) {
|
||||
prefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
@@ -215,4 +269,40 @@ class SecurePrefs(context: Context) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBoolWithMigration(newKey: String, oldKey: String?, defaultValue: Boolean): Boolean {
|
||||
if (prefs.contains(newKey)) {
|
||||
return prefs.getBoolean(newKey, defaultValue)
|
||||
}
|
||||
if (oldKey != null && prefs.contains(oldKey)) {
|
||||
val value = prefs.getBoolean(oldKey, defaultValue)
|
||||
prefs.edit { putBoolean(newKey, value) }
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private fun readStringWithMigration(newKey: String, oldKey: String?, defaultValue: String): String {
|
||||
if (prefs.contains(newKey)) {
|
||||
return prefs.getString(newKey, defaultValue) ?: defaultValue
|
||||
}
|
||||
if (oldKey != null && prefs.contains(oldKey)) {
|
||||
val value = prefs.getString(oldKey, defaultValue) ?: defaultValue
|
||||
prefs.edit { putString(newKey, value) }
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private fun readIntWithMigration(newKey: String, oldKey: String?, defaultValue: Int): Int {
|
||||
if (prefs.contains(newKey)) {
|
||||
return prefs.getInt(newKey, defaultValue)
|
||||
}
|
||||
if (oldKey != null && prefs.contains(oldKey)) {
|
||||
val value = prefs.getInt(oldKey, defaultValue)
|
||||
prefs.edit { putInt(newKey, value) }
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ object WakeWords {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||
val parsed = parseCommaSeparated(input)
|
||||
return if (parsed == current) null else parsed
|
||||
}
|
||||
|
||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||
val cleaned =
|
||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||
return cleaned.ifEmpty { defaults }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
data class BridgeEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val lanHost: String? = null,
|
||||
val tailnetDns: String? = null,
|
||||
val gatewayPort: Int? = null,
|
||||
val bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
BridgeEndpoint(
|
||||
stableId = "manual|$host|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class BridgePairingClient {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
val displayName: String?,
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
val caps: List<String>?,
|
||||
val commands: List<String>?,
|
||||
)
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.clawdbot.android.BuildConfig
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URI
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class BridgeSession(
|
||||
private val scope: CoroutineScope,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
) {
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
val displayName: String?,
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
val caps: List<String>?,
|
||||
val commands: List<String>?,
|
||||
)
|
||||
|
||||
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
|
||||
|
||||
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||
companion object {
|
||||
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||
fun error(code: String, message: String) =
|
||||
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||
}
|
||||
}
|
||||
|
||||
data class ErrorShape(val code: String, val message: String)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
|
||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("event"))
|
||||
put("event", JsonPrimitive(event))
|
||||
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?): String {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
|
||||
},
|
||||
)
|
||||
val res = deferred.await()
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
|
||||
val remoteAddress: String? =
|
||||
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
writeLock.withLock {
|
||||
writer.write(obj.toString())
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fun closeQuietly() {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
|
||||
private suspend fun runLoop() {
|
||||
var attempt = 0
|
||||
while (scope.isActive) {
|
||||
val target = desired
|
||||
if (target == null) {
|
||||
currentConnection?.closeQuietly()
|
||||
currentConnection = null
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
|
||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||
delay(sleepMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
}
|
||||
"ping" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: ""
|
||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||
}
|
||||
"res" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: continue
|
||||
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||
val payloadJson = frame["payloadJSON"].asStringOrNull()
|
||||
val error =
|
||||
frame["error"]?.let {
|
||||
val obj = it.asObjectOrNull() ?: return@let null
|
||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||
ErrorShape(code, msg)
|
||||
}
|
||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||
}
|
||||
"invoke" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: continue
|
||||
val command = frame["command"].asStringOrNull() ?: ""
|
||||
val params = frame["paramsJSON"].asStringOrNull()
|
||||
val result =
|
||||
try {
|
||||
onInvoke(InvokeRequest(id, command, params))
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("invoke-res"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("ok", JsonPrimitive(result.ok))
|
||||
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
if (result.error != null) {
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", JsonPrimitive(result.error.code))
|
||||
put("message", JsonPrimitive(result.error.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
val fallbackHost =
|
||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
val host = raw?.trim()?.lowercase().orEmpty()
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (host == "::1") return true
|
||||
if (host == "0.0.0.0" || host == "::") return true
|
||||
return host.startsWith("127.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
val c = content.trim()
|
||||
when {
|
||||
c.equals("true", ignoreCase = true) -> true
|
||||
c.equals("false", ignoreCase = true) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.clawdbot.android.chat
|
||||
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.gateway.GatewaySession
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -20,8 +20,9 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
class ChatController(
|
||||
private val scope: CoroutineScope,
|
||||
private val session: BridgeSession,
|
||||
private val session: GatewaySession,
|
||||
private val json: Json,
|
||||
private val supportsChatSubscribe: Boolean,
|
||||
) {
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
@@ -71,12 +72,21 @@ class ChatController(
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
fun load(sessionKey: String = "main") {
|
||||
fun load(sessionKey: String) {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
val trimmed = mainSessionKey.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (_sessionKey.value == trimmed) return
|
||||
if (_sessionKey.value != "main") return
|
||||
_sessionKey.value = trimmed
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
@@ -215,7 +225,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
when (event) {
|
||||
"tick" -> {
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
@@ -250,10 +260,12 @@ class ChatController(
|
||||
|
||||
val key = _sessionKey.value
|
||||
try {
|
||||
try {
|
||||
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
if (supportsChatSubscribe) {
|
||||
try {
|
||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import com.clawdbot.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class DeviceIdentity(
|
||||
val deviceId: String,
|
||||
val publicKeyRawBase64: String,
|
||||
val privateKeyPkcs8Base64: String,
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "clawdbot/identity/device.json")
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
val existing = load()
|
||||
if (existing != null) {
|
||||
val derived = deriveDeviceId(existing.publicKeyRawBase64)
|
||||
if (derived != null && derived != existing.deviceId) {
|
||||
val updated = existing.copy(deviceId = derived)
|
||||
save(updated)
|
||||
return updated
|
||||
}
|
||||
return existing
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||
val keyFactory = KeyFactory.getInstance("Ed25519")
|
||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||
val signature = Signature.getInstance("Ed25519")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||
base64UrlEncode(signature.sign())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
base64UrlEncode(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): DeviceIdentity? {
|
||||
return try {
|
||||
if (!identityFile.exists()) return null
|
||||
val raw = identityFile.readText(Charsets.UTF_8)
|
||||
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
|
||||
if (decoded.deviceId.isBlank() ||
|
||||
decoded.publicKeyRawBase64.isBlank() ||
|
||||
decoded.privateKeyPkcs8Base64.isBlank()
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
||||
val spki = keyPair.public.encoded
|
||||
val rawPublic = stripSpkiPrefix(spki)
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
val privateKey = keyPair.private.encoded
|
||||
return DeviceIdentity(
|
||||
deviceId = deviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
||||
createdAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||
sha256Hex(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
|
||||
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
|
||||
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
|
||||
) {
|
||||
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
|
||||
}
|
||||
return spki
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(data: ByteArray): String {
|
||||
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ED25519_SPKI_PREFIX =
|
||||
byteArrayOf(
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
@@ -44,21 +44,21 @@ import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class BridgeDiscovery(
|
||||
class GatewayDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdbot-bridge._tcp."
|
||||
private val serviceType = "_clawdbot-gw._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/BridgeDiscovery"
|
||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||
|
||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
@@ -77,7 +77,7 @@ class BridgeDiscovery(
|
||||
override fun onDiscoveryStopped(serviceType: String) {}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
|
||||
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return
|
||||
resolve(serviceInfo)
|
||||
}
|
||||
|
||||
@@ -141,11 +141,12 @@ class BridgeDiscovery(
|
||||
val lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
@@ -153,8 +154,9 @@ class BridgeDiscovery(
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
@@ -163,7 +165,7 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_bridges.value =
|
||||
_gateways.value =
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
@@ -182,7 +184,7 @@ class BridgeDiscovery(
|
||||
}
|
||||
|
||||
return when {
|
||||
localCount == 0 && wideRcode == null -> "Searching for bridges…"
|
||||
localCount == 0 && wideRcode == null -> "Searching for gateways…"
|
||||
localCount == 0 -> "$wide"
|
||||
else -> "Local: $localCount • $wide"
|
||||
}
|
||||
@@ -209,12 +211,17 @@ class BridgeDiscovery(
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
|
||||
|
||||
val next = LinkedHashMap<String, BridgeEndpoint>()
|
||||
val next = LinkedHashMap<String, GatewayEndpoint>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv =
|
||||
@@ -250,11 +257,12 @@ class BridgeDiscovery(
|
||||
val lanHost = txtValue(txt, "lanHost")
|
||||
val tailnetDns = txtValue(txt, "tailnetDns")
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "gatewayTls")
|
||||
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
@@ -262,8 +270,9 @@ class BridgeDiscovery(
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -474,6 +483,11 @@ class BridgeDiscovery(
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
data class GatewayEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val lanHost: String? = null,
|
||||
val tailnetDns: String? = null,
|
||||
val gatewayPort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): GatewayEndpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|${host.lowercase()}|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 3
|
||||
@@ -0,0 +1,683 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import android.util.Log
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
data class GatewayClientInfo(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
val version: String,
|
||||
val platform: String,
|
||||
val mode: String,
|
||||
val instanceId: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
data class GatewayConnectOptions(
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
val caps: List<String>,
|
||||
val commands: List<String>,
|
||||
val permissions: Map<String, Boolean>,
|
||||
val client: GatewayClientInfo,
|
||||
val userAgent: String? = null,
|
||||
)
|
||||
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
data class InvokeRequest(
|
||||
val id: String,
|
||||
val nodeId: String,
|
||||
val command: String,
|
||||
val paramsJson: String?,
|
||||
val timeoutMs: Long?,
|
||||
)
|
||||
|
||||
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||
companion object {
|
||||
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||
fun error(code: String, message: String) =
|
||||
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||
}
|
||||
}
|
||||
|
||||
data class ErrorShape(val code: String, val message: String)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private data class DesiredConnection(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val token: String?,
|
||||
val password: String?,
|
||||
val options: GatewayConnectOptions,
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
password: String?,
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
desired = DesiredConnection(endpoint, token, password, options, tls)
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
currentConnection?.closeQuietly()
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("event", JsonPrimitive(event))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(payloadJson))
|
||||
} else {
|
||||
put("payloadJSON", JsonNull)
|
||||
}
|
||||
}
|
||||
try {
|
||||
conn.request("node.event", params, timeoutMs = 8_000)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("ClawdbotGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
private val endpoint: GatewayEndpoint,
|
||||
private val token: String?,
|
||||
private val password: String?,
|
||||
private val options: GatewayConnectOptions,
|
||||
private val tls: GatewayTlsParams?,
|
||||
) {
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "ClawdbotGateway"
|
||||
|
||||
val remoteAddress: String =
|
||||
if (endpoint.host.contains(":")) {
|
||||
"[${endpoint.host}]:${endpoint.port}"
|
||||
} else {
|
||||
"${endpoint.host}:${endpoint.port}"
|
||||
}
|
||||
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
} catch (err: Throwable) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
throw IllegalStateException("request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
socket?.send(jsonString)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
socket?.close(1000, "bye")
|
||||
socket = null
|
||||
closedDeferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildClient(): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
if (tlsConfig != null) {
|
||||
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
|
||||
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private inner class Listener : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
scope.launch {
|
||||
try {
|
||||
val nonce = awaitConnectNonce()
|
||||
sendConnect(nonce)
|
||||
} catch (err: Throwable) {
|
||||
connectDeferred.completeExceptionally(err)
|
||||
closeQuietly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch { handleMessage(text) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(t)
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (!connectDeferred.isCompleted) {
|
||||
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
|
||||
}
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
failPending()
|
||||
closedDeferred.complete(Unit)
|
||||
onDisconnected("Gateway closed: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect(connectNonce: String?) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().orEmpty()
|
||||
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
|
||||
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
|
||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
||||
val res = request("connect", payload, timeoutMs = 8_000)
|
||||
if (!res.ok) {
|
||||
val msg = res.error?.message ?: "connect failed"
|
||||
if (canFallbackToShared) {
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
}
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||
val sessionDefaults =
|
||||
obj["snapshot"].asObjectOrNull()
|
||||
?.get("sessionDefaults").asObjectOrNull()
|
||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(
|
||||
identity: DeviceIdentity,
|
||||
connectNonce: String?,
|
||||
authToken: String,
|
||||
authPassword: String?,
|
||||
): JsonObject {
|
||||
val client = options.client
|
||||
val locale = Locale.getDefault().toLanguageTag()
|
||||
val clientObj =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(client.id))
|
||||
client.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
put("version", JsonPrimitive(client.version))
|
||||
put("platform", JsonPrimitive(client.platform))
|
||||
put("mode", JsonPrimitive(client.mode))
|
||||
client.instanceId?.let { put("instanceId", JsonPrimitive(it)) }
|
||||
client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
val password = authPassword?.trim().orEmpty()
|
||||
val authJson =
|
||||
when {
|
||||
authToken.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("token", JsonPrimitive(authToken))
|
||||
}
|
||||
password.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("password", JsonPrimitive(password))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
role = options.role,
|
||||
scopes = options.scopes,
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
val deviceJson =
|
||||
if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) {
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(identity.deviceId))
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
if (!connectNonce.isNullOrBlank()) {
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return buildJsonObject {
|
||||
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||
put("client", clientObj)
|
||||
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
|
||||
if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive)))
|
||||
if (options.permissions.isNotEmpty()) {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
options.permissions.forEach { (key, value) ->
|
||||
put(key, JsonPrimitive(value))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
put("role", JsonPrimitive(options.role))
|
||||
if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive)))
|
||||
authJson?.let { put("auth", it) }
|
||||
deviceJson?.let { put("device", it) }
|
||||
put("locale", JsonPrimitive(locale))
|
||||
options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
put("userAgent", JsonPrimitive(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(text: String) {
|
||||
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"res" -> handleResponse(frame)
|
||||
"event" -> handleEvent(frame)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResponse(frame: JsonObject) {
|
||||
val id = frame["id"].asStringOrNull() ?: return
|
||||
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
|
||||
val error =
|
||||
frame["error"]?.asObjectOrNull()?.let { obj ->
|
||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||
ErrorShape(code, msg)
|
||||
}
|
||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||
}
|
||||
|
||||
private fun handleEvent(frame: JsonObject) {
|
||||
val event = frame["event"].asStringOrNull() ?: return
|
||||
val payloadJson =
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted) {
|
||||
connectNonceDeferred.complete(nonce)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||
handleInvokeEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String? {
|
||||
if (isLoopbackHost(endpoint.host)) return null
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractConnectNonce(payloadJson: String?): String? {
|
||||
if (payloadJson.isNullOrBlank()) return null
|
||||
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
|
||||
return obj["nonce"].asStringOrNull()
|
||||
}
|
||||
|
||||
private fun handleInvokeEvent(payloadJson: String) {
|
||||
val payload =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
val id = payload["id"].asStringOrNull() ?: return
|
||||
val nodeId = payload["nodeId"].asStringOrNull() ?: return
|
||||
val command = payload["command"].asStringOrNull() ?: return
|
||||
val params =
|
||||
payload["paramsJSON"].asStringOrNull()
|
||||
?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() }
|
||||
val timeoutMs = payload["timeoutMs"].asLongOrNull()
|
||||
scope.launch {
|
||||
val result =
|
||||
try {
|
||||
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
|
||||
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("nodeId", JsonPrimitive(nodeId))
|
||||
put("ok", JsonPrimitive(result.ok))
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (result.payloadJson != null) {
|
||||
put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
}
|
||||
result.error?.let { err ->
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", JsonPrimitive(err.code))
|
||||
put("message", JsonPrimitive(err.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runLoop() {
|
||||
var attempt = 0
|
||||
while (scope.isActive) {
|
||||
val target = desired
|
||||
if (target == null) {
|
||||
currentConnection?.closeQuietly()
|
||||
currentConnection = null
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(target)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||
delay(sleepMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
||||
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
|
||||
currentConnection = conn
|
||||
try {
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
currentConnection = null
|
||||
canvasHostUrl = null
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDeviceAuthPayload(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||
val parts =
|
||||
mutableListOf(
|
||||
version,
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
)
|
||||
if (!nonce.isNullOrBlank()) {
|
||||
parts.add(nonce)
|
||||
}
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
val fallbackHost =
|
||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
val host = raw?.trim()?.lowercase().orEmpty()
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (host == "::1") return true
|
||||
if (host == "0.0.0.0" || host == "::") return true
|
||||
return host.startsWith("127.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
val c = content.trim()
|
||||
when {
|
||||
c.equals("true", ignoreCase = true) -> true
|
||||
c.equals("false", ignoreCase = true) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
val trimmed = payload.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(trimmed)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class GatewayTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
data class GatewayTlsConfig(
|
||||
val sslSocketFactory: SSLSocketFactory,
|
||||
val trustManager: X509TrustManager,
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
): GatewayTlsConfig? {
|
||||
if (params == null) return null
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("gateway TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
return GatewayTlsConfig(
|
||||
sslSocketFactory = context.socketFactory,
|
||||
trustManager = trustManager,
|
||||
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -118,7 +118,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
contentDescription = "Approval pending",
|
||||
)
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if (screenRecordActive) {
|
||||
return@remember StatusActivity(
|
||||
@@ -179,14 +179,14 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
null
|
||||
}
|
||||
|
||||
val bridgeState =
|
||||
val gatewayState =
|
||||
remember(serverName, statusText) {
|
||||
when {
|
||||
serverName != null -> BridgeState.Connected
|
||||
serverName != null -> GatewayState.Connected
|
||||
statusText.contains("connecting", ignoreCase = true) ||
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||
else -> BridgeState.Disconnected
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> GatewayState.Error
|
||||
else -> GatewayState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||
StatusPill(
|
||||
bridge = bridgeState,
|
||||
gateway = gatewayState,
|
||||
voiceEnabled = voiceEnabled,
|
||||
activity = activity,
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
@@ -48,7 +50,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -57,6 +63,7 @@ import com.clawdbot.android.LocationMode
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.NodeForegroundService
|
||||
import com.clawdbot.android.VoiceWakeMode
|
||||
import com.clawdbot.android.WakeWords
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
@@ -74,16 +81,19 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val bridges by viewModel.bridges.collectAsState()
|
||||
val gateways by viewModel.gateways.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||
val deviceModel =
|
||||
remember {
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
@@ -102,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
val commitWakeWords = {
|
||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||
if (parsed != null) {
|
||||
viewModel.setWakeWords(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
@@ -163,7 +179,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val smsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
smsPermissionGranted = granted
|
||||
viewModel.refreshBridgeHello()
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun setCameraEnabledChecked(checked: Boolean) {
|
||||
@@ -223,20 +239,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val visibleBridges =
|
||||
val visibleGateways =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
gateways.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
} else {
|
||||
bridges
|
||||
gateways
|
||||
}
|
||||
|
||||
val bridgeDiscoveryFooterText =
|
||||
if (visibleBridges.isEmpty()) {
|
||||
val gatewayDiscoveryFooterText =
|
||||
if (visibleGateways.isEmpty()) {
|
||||
discoveryStatusText
|
||||
} else if (isConnected) {
|
||||
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
"Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found"
|
||||
} else {
|
||||
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found"
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
@@ -250,7 +266,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
|
||||
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen.
|
||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
OutlinedTextField(
|
||||
@@ -266,8 +282,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Bridge
|
||||
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
|
||||
// Gateway
|
||||
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) }
|
||||
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
||||
if (serverName != null) {
|
||||
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
||||
@@ -291,31 +307,30 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
if (!isConnected || visibleBridges.isNotEmpty()) {
|
||||
if (!isConnected || visibleGateways.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) "Other Bridges" else "Discovered Bridges",
|
||||
if (isConnected) "Other Gateways" else "Discovered Gateways",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (!isConnected && visibleBridges.isEmpty()) {
|
||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
if (!isConnected && visibleGateways.isEmpty()) {
|
||||
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} else {
|
||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||
items(items = visibleGateways, key = { it.stableId }) { gateway ->
|
||||
val detailLines =
|
||||
buildList {
|
||||
add("IP: ${bridge.host}:${bridge.port}")
|
||||
bridge.lanHost?.let { add("LAN: $it") }
|
||||
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
||||
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
||||
val gw = bridge.gatewayPort?.toString() ?: "—"
|
||||
val br = (bridge.bridgePort ?: bridge.port).toString()
|
||||
val canvas = bridge.canvasPort?.toString() ?: "—"
|
||||
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
||||
add("IP: ${gateway.host}:${gateway.port}")
|
||||
gateway.lanHost?.let { add("LAN: $it") }
|
||||
gateway.tailnetDns?.let { add("Tailnet: $it") }
|
||||
if (gateway.gatewayPort != null || gateway.canvasPort != null) {
|
||||
val gw = (gateway.gatewayPort ?: gateway.port).toString()
|
||||
val canvas = gateway.canvasPort?.toString() ?: "—"
|
||||
add("Ports: gw $gw · canvas $canvas")
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(bridge.name) },
|
||||
headlineContent = { Text(gateway.name) },
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
detailLines.forEach { line ->
|
||||
@@ -327,7 +342,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
Button(
|
||||
onClick = {
|
||||
NodeForegroundService.start(context)
|
||||
viewModel.connect(bridge)
|
||||
viewModel.connect(gateway)
|
||||
},
|
||||
) {
|
||||
Text("Connect")
|
||||
@@ -338,7 +353,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
bridgeDiscoveryFooterText,
|
||||
gatewayDiscoveryFooterText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -352,7 +367,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Advanced") },
|
||||
supportingContent = { Text("Manual bridge connection") },
|
||||
supportingContent = { Text("Manual gateway connection") },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
@@ -369,7 +384,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
AnimatedVisibility(visible = advancedExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Use Manual Bridge") },
|
||||
headlineContent = { Text("Use Manual Gateway") },
|
||||
supportingContent = { Text("Use this when discovery is blocked.") },
|
||||
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
||||
)
|
||||
@@ -388,6 +403,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Require TLS") },
|
||||
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
||||
trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) },
|
||||
modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f),
|
||||
)
|
||||
|
||||
val hostOk = manualHost.trim().isNotEmpty()
|
||||
val portOk = manualPort in 1..65535
|
||||
@@ -474,29 +495,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
value = wakeWordsText,
|
||||
onValueChange = setWakeWordsText,
|
||||
label = { Text("Wake Words (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
wakeWordsHadFocus = true
|
||||
} else if (wakeWordsHadFocus) {
|
||||
wakeWordsHadFocus = false
|
||||
commitWakeWords()
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
commitWakeWords()
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||
viewModel.setWakeWords(parsed)
|
||||
},
|
||||
enabled = isConnected,
|
||||
) {
|
||||
Text("Save + Sync")
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
||||
}
|
||||
}
|
||||
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) {
|
||||
"Any node can edit wake words. Changes sync via the gateway bridge."
|
||||
"Any node can edit wake words. Changes sync via the gateway."
|
||||
} else {
|
||||
"Connect to a gateway to sync wake words globally."
|
||||
},
|
||||
@@ -511,7 +534,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Allow Camera") },
|
||||
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
|
||||
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") },
|
||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||
)
|
||||
}
|
||||
@@ -538,7 +561,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (smsPermissionAvailable) {
|
||||
"Allow the bridge to send SMS from this device."
|
||||
"Allow the gateway to send SMS from this device."
|
||||
} else {
|
||||
"SMS requires a device with telephony hardware."
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatusPill(
|
||||
bridge: BridgeState,
|
||||
gateway: GatewayState,
|
||||
voiceEnabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -49,11 +49,11 @@ fun StatusPill(
|
||||
Surface(
|
||||
modifier = Modifier.size(9.dp),
|
||||
shape = CircleShape,
|
||||
color = bridge.color,
|
||||
color = gateway.color,
|
||||
) {}
|
||||
|
||||
Text(
|
||||
text = bridge.title,
|
||||
text = gateway.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ data class StatusActivity(
|
||||
val tint: Color? = null,
|
||||
)
|
||||
|
||||
enum class BridgeState(val title: String, val color: Color) {
|
||||
enum class GatewayState(val title: String, val color: Color) {
|
||||
Connected("Connected", Color(0xFF2ECC71)),
|
||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||
Error("Error", Color(0xFFE74C3C)),
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
@@ -61,7 +62,7 @@ fun ChatComposer(
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
LaunchedEffect(mainSessionKey) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
|
||||
@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val aliasKey = if (mainKey == "main") null else "main"
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (aliasKey != null && entry.key == aliasKey) continue
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
included.add(mainKey)
|
||||
} else if (current == mainKey) {
|
||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
||||
included.add(mainKey)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
|
||||
@@ -20,7 +20,8 @@ import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.gateway.GatewaySession
|
||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
||||
import com.clawdbot.android.normalizeMainKey
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -45,6 +46,9 @@ import kotlin.math.max
|
||||
class TalkModeManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val session: GatewaySession,
|
||||
private val supportsChatSubscribe: Boolean,
|
||||
private val isConnected: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "TalkMode"
|
||||
@@ -98,7 +102,6 @@ class TalkModeManager(
|
||||
private var modelOverrideActive = false
|
||||
private var mainSessionKey: String = "main"
|
||||
|
||||
private var session: BridgeSession? = null
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingFinal: CompletableDeferred<Boolean>? = null
|
||||
private var chatSubscribedSessionKey: String? = null
|
||||
@@ -111,9 +114,11 @@ class TalkModeManager(
|
||||
private var systemTtsPending: CompletableDeferred<Unit>? = null
|
||||
private var systemTtsPendingId: String? = null
|
||||
|
||||
fun attachSession(session: BridgeSession) {
|
||||
this.session = session
|
||||
chatSubscribedSessionKey = null
|
||||
fun setMainSessionKey(sessionKey: String?) {
|
||||
val trimmed = sessionKey?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
||||
mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
@@ -128,7 +133,7 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
if (event != "chat") return
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
val pending = pendingRunId ?: return
|
||||
@@ -298,25 +303,24 @@ class TalkModeManager(
|
||||
|
||||
reloadConfig()
|
||||
val prompt = buildPrompt(transcript)
|
||||
val bridge = session
|
||||
if (bridge == null) {
|
||||
_statusText.value = "Bridge not connected"
|
||||
Log.w(tag, "finalize: bridge not connected")
|
||||
if (!isConnected()) {
|
||||
_statusText.value = "Gateway not connected"
|
||||
Log.w(tag, "finalize: gateway not connected")
|
||||
start()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey)
|
||||
subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey)
|
||||
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
||||
val runId = sendChat(prompt, bridge)
|
||||
val runId = sendChat(prompt, session)
|
||||
Log.d(tag, "chat.send ok runId=$runId")
|
||||
val ok = waitForChatFinal(runId)
|
||||
if (!ok) {
|
||||
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
||||
}
|
||||
val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000)
|
||||
val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (assistant.isNullOrBlank()) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "assistant text timeout runId=$runId")
|
||||
@@ -335,12 +339,13 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) {
|
||||
private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) {
|
||||
if (!supportsChatSubscribe) return
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (chatSubscribedSessionKey == key) return
|
||||
try {
|
||||
bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
chatSubscribedSessionKey = key
|
||||
Log.d(tag, "chat.subscribe ok sessionKey=$key")
|
||||
} catch (err: Throwable) {
|
||||
@@ -362,7 +367,7 @@ class TalkModeManager(
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
private suspend fun sendChat(message: String, bridge: BridgeSession): String {
|
||||
private suspend fun sendChat(message: String, session: GatewaySession): String {
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val params =
|
||||
buildJsonObject {
|
||||
@@ -372,7 +377,7 @@ class TalkModeManager(
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
}
|
||||
val res = bridge.request("chat.send", params.toString())
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
@@ -403,13 +408,13 @@ class TalkModeManager(
|
||||
}
|
||||
|
||||
private suspend fun waitForAssistantText(
|
||||
bridge: BridgeSession,
|
||||
session: GatewaySession,
|
||||
sinceSeconds: Double,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
val text = fetchLatestAssistantText(bridge, sinceSeconds)
|
||||
val text = fetchLatestAssistantText(session, sinceSeconds)
|
||||
if (!text.isNullOrBlank()) return text
|
||||
delay(300)
|
||||
}
|
||||
@@ -417,11 +422,11 @@ class TalkModeManager(
|
||||
}
|
||||
|
||||
private suspend fun fetchLatestAssistantText(
|
||||
bridge: BridgeSession,
|
||||
session: GatewaySession,
|
||||
sinceSeconds: Double? = null,
|
||||
): String? {
|
||||
val key = mainSessionKey.ifBlank { "main" }
|
||||
val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}")
|
||||
val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
||||
val messages = root["messages"] as? JsonArray ?: return null
|
||||
for (item in messages.reversed()) {
|
||||
@@ -805,12 +810,11 @@ class TalkModeManager(
|
||||
}
|
||||
|
||||
private suspend fun reloadConfig() {
|
||||
val bridge = session ?: return
|
||||
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
|
||||
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
||||
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
try {
|
||||
val res = bridge.request("config.get", "{}")
|
||||
val res = session.request("config.get", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
@@ -827,7 +831,9 @@ class TalkModeManager(
|
||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
|
||||
mainSessionKey = mainKey
|
||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
||||
mainSessionKey = mainKey
|
||||
}
|
||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = aliases
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class WakeWordsTest {
|
||||
@@ -32,5 +33,18 @@ class WakeWordsTest {
|
||||
assertEquals("w1", sanitized.first())
|
||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIfChangedSkipsWhenUnchanged() {
|
||||
val current = listOf("clawd", "claude")
|
||||
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
|
||||
assertNull(parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIfChangedReturnsUpdatedList() {
|
||||
val current = listOf("clawd")
|
||||
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
|
||||
assertEquals(listOf("clawd", "jarvis"), parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class BridgeEndpointKotestTest : StringSpec({
|
||||
"manual endpoint builds stable id + name" {
|
||||
val endpoint = BridgeEndpoint.manual("10.0.0.5", 18790)
|
||||
endpoint.stableId shouldBe "manual|10.0.0.5|18790"
|
||||
endpoint.name shouldBe "10.0.0.5:18790"
|
||||
endpoint.host shouldBe "10.0.0.5"
|
||||
endpoint.port shouldBe 18790
|
||||
}
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.ServerSocket
|
||||
|
||||
class BridgePairingClientTest {
|
||||
@Test
|
||||
fun helloOkReturnsExistingToken() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
val sock = ss.accept()
|
||||
sock.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val hello = reader.readLine()
|
||||
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val client = BridgePairingClient()
|
||||
val res =
|
||||
client.pairAndHello(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = "token-123",
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = "SM-X000",
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
assertTrue(res.ok)
|
||||
assertEquals("token-123", res.token)
|
||||
server.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
val sock = ss.accept()
|
||||
sock.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
reader.readLine() // hello
|
||||
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
|
||||
val pairReq = reader.readLine()
|
||||
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
|
||||
writer.write("""{"type":"pair-ok","token":"new-token"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val client = BridgePairingClient()
|
||||
val res =
|
||||
client.pairAndHello(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = "SM-X000",
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
assertTrue(res.ok)
|
||||
assertEquals("new-token", res.token)
|
||||
server.await()
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BridgeSessionTest {
|
||||
@Test
|
||||
fun requestReturnsPayloadJson() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
)
|
||||
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
val sock = ss.accept()
|
||||
sock.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val hello = reader.readLine()
|
||||
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||
writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18789"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
|
||||
val req = reader.readLine()
|
||||
assertTrue(req.contains("\"type\":\"req\""))
|
||||
val id = extractJsonString(req, "id")
|
||||
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.connect(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = null,
|
||||
modelIdentifier = null,
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
|
||||
connected.await()
|
||||
assertEquals("http://127.0.0.1:18789", session.currentCanvasHostUrl())
|
||||
val payload = session.request(method = "health", paramsJson = null)
|
||||
assertEquals("""{"value":123}""", payload)
|
||||
server.await()
|
||||
|
||||
session.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestThrowsOnErrorResponse() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
)
|
||||
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
val sock = ss.accept()
|
||||
sock.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
reader.readLine() // hello
|
||||
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
|
||||
val req = reader.readLine()
|
||||
val id = extractJsonString(req, "id")
|
||||
writer.write(
|
||||
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
|
||||
)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.connect(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = null,
|
||||
modelIdentifier = null,
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
connected.await()
|
||||
|
||||
try {
|
||||
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
|
||||
throw AssertionError("expected request() to throw")
|
||||
} catch (e: IllegalStateException) {
|
||||
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
|
||||
}
|
||||
server.await()
|
||||
|
||||
session.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.complete(Unit) },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||
)
|
||||
|
||||
val invokeResLine = CompletableDeferred<String>()
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
val sock = ss.accept()
|
||||
sock.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
reader.readLine() // hello
|
||||
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
|
||||
// Ask the node to invoke something; handler will throw.
|
||||
writer.write("""{"type":"invoke","id":"i1","command":"canvas.snapshot","paramsJSON":null}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
|
||||
val res = reader.readLine()
|
||||
invokeResLine.complete(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.connect(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = null,
|
||||
modelIdentifier = null,
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
connected.await()
|
||||
|
||||
// Give the reader loop time to process.
|
||||
val line = invokeResLine.await()
|
||||
assertTrue(line.contains("\"type\":\"invoke-res\""))
|
||||
assertTrue(line.contains("\"ok\":false"))
|
||||
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
|
||||
assertTrue(line.contains("\"message\":\"boom\""))
|
||||
server.await()
|
||||
|
||||
session.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test(timeout = 12_000)
|
||||
fun reconnectsAfterBridgeClosesDuringHello() = runBlocking {
|
||||
val serverSocket = ServerSocket(0)
|
||||
val port = serverSocket.localPort
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val connected = CountDownLatch(1)
|
||||
val connectionsSeen = CountDownLatch(2)
|
||||
|
||||
val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { _, _ -> connected.countDown() },
|
||||
onDisconnected = { /* ignore */ },
|
||||
onEvent = { _, _ -> /* ignore */ },
|
||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||
)
|
||||
|
||||
val server =
|
||||
async(Dispatchers.IO) {
|
||||
serverSocket.use { ss ->
|
||||
// First connection: read hello, then close (no response).
|
||||
val sock1 = ss.accept()
|
||||
sock1.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
reader.readLine() // hello
|
||||
connectionsSeen.countDown()
|
||||
}
|
||||
|
||||
// Second connection: complete hello.
|
||||
val sock2 = ss.accept()
|
||||
sock2.use { s ->
|
||||
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||
reader.readLine() // hello
|
||||
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
connectionsSeen.countDown()
|
||||
Thread.sleep(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.connect(
|
||||
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = "node-1",
|
||||
displayName = "Android Node",
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = "test",
|
||||
deviceFamily = null,
|
||||
modelIdentifier = null,
|
||||
caps = null,
|
||||
commands = null,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("expected two connection attempts", connectionsSeen.await(8, TimeUnit.SECONDS))
|
||||
assertTrue("expected session to connect", connected.await(8, TimeUnit.SECONDS))
|
||||
|
||||
session.disconnect()
|
||||
scope.cancel()
|
||||
server.await()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractJsonString(raw: String, key: String): String {
|
||||
val needle = "\"$key\":\""
|
||||
val start = raw.indexOf(needle)
|
||||
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
|
||||
val from = start + needle.length
|
||||
val end = raw.indexOf('"', from)
|
||||
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
|
||||
return raw.substring(from, end)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.clawdbot.android.bridge
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -19,7 +19,7 @@ class SessionFiltersTest {
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||
)
|
||||
|
||||
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class SessionFiltersTest {
|
||||
val recent = now - 10 * 60 * 1000L
|
||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||
|
||||
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
actor BridgeClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var lineBuffer = Data()
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
}
|
||||
|
||||
onStatus?("Authenticating…")
|
||||
try await self.send(hello, over: connection)
|
||||
|
||||
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||
])
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
switch first.base.type {
|
||||
case "hello-ok":
|
||||
// We only return a token if we have one; callers should treat empty as "no token yet".
|
||||
return hello.token ?? ""
|
||||
|
||||
case "error":
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
}
|
||||
|
||||
onStatus?("Requesting approval…")
|
||||
try await self.send(
|
||||
BridgePairRequest(
|
||||
nodeId: hello.nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
caps: hello.caps,
|
||||
commands: hello.commands),
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||
case "error":
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||
])
|
||||
}
|
||||
|
||||
return ok.token
|
||||
|
||||
default:
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReceivedFrame {
|
||||
var base: BridgeBaseFrame
|
||||
var data: Data
|
||||
}
|
||||
|
||||
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
|
||||
guard let lineData = try await self.receiveLineData(over: connection) else {
|
||||
return nil
|
||||
}
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||
return ReceivedFrame(base: base, data: lineData)
|
||||
}
|
||||
|
||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
|
||||
while true {
|
||||
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
|
||||
let line = self.lineBuffer.prefix(upTo: idx)
|
||||
self.lineBuffer.removeSubrange(...idx)
|
||||
return Data(line)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk(over: connection)
|
||||
if chunk.isEmpty { return nil }
|
||||
self.lineBuffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimeoutError: LocalizedError, Sendable {
|
||||
var purpose: String
|
||||
var seconds: Int
|
||||
|
||||
var errorDescription: String? {
|
||||
if self.purpose == "pairing approval" {
|
||||
return
|
||||
"Timed out waiting for approval (\(self.seconds)s). " +
|
||||
"Approve the node on your gateway and try again."
|
||||
}
|
||||
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
seconds: Int,
|
||||
purpose: String,
|
||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: Double(seconds),
|
||||
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
|
||||
operation: op)
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value = false
|
||||
|
||||
func trySet() -> Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
if self.value { return false }
|
||||
self.value = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
let didResume = ResumeFlag()
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if didResume.trySet() { cont.resume(returning: ()) }
|
||||
case let .failed(err):
|
||||
if didResume.trySet() { cont.resume(throwing: err) }
|
||||
case let .waiting(err):
|
||||
if didResume.trySet() { cont.resume(throwing: err) }
|
||||
case .cancelled:
|
||||
if didResume.trySet() {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
]))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum BridgeEndpointID {
|
||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
|
||||
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||
return "\(type)|\(domain)|\(normalizedName)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||
let decoded = BonjourEscapes.decode(rawName)
|
||||
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
actor BridgeSession {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
var errorDescription: String? { self.message }
|
||||
}
|
||||
|
||||
enum State: Sendable, Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case connected(serverName: String)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var queue: DispatchQueue?
|
||||
private var buffer = Data()
|
||||
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||
|
||||
private(set) var state: State = .idle
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
}
|
||||
|
||||
func currentRemoteAddress() -> String? {
|
||||
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
|
||||
return Self.prettyRemoteEndpoint(endpoint)
|
||||
}
|
||||
|
||||
private static func prettyRemoteEndpoint(_ endpoint: NWEndpoint) -> String? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
let hostString = Self.prettyHostString(host)
|
||||
if hostString.contains(":") {
|
||||
return "[\(hostString)]:\(port)"
|
||||
}
|
||||
return "\(hostString):\(port)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private static func prettyHostString(_ host: NWEndpoint.Host) -> String {
|
||||
var hostString = String(describing: host)
|
||||
hostString = hostString.replacingOccurrences(of: "::ffff:", with: "")
|
||||
|
||||
guard let percentIndex = hostString.firstIndex(of: "%") else { return hostString }
|
||||
|
||||
let prefix = hostString[..<percentIndex]
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
|
||||
let isIPAddressPrefix = prefix.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
if isIPAddressPrefix {
|
||||
return String(prefix)
|
||||
}
|
||||
|
||||
return hostString
|
||||
}
|
||||
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.state = .connecting
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||
self.connection = connection
|
||||
self.queue = queue
|
||||
|
||||
let stateStream = Self.makeStateStream(for: connection)
|
||||
connection.start(queue: queue)
|
||||
|
||||
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||
|
||||
try await Self.withTimeout(seconds: 6) {
|
||||
try await self.send(hello)
|
||||
}
|
||||
|
||||
guard let line = try await Self.withTimeout(seconds: 6, operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
if base.type == "hello-ok" {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
} else {
|
||||
self.state = .failed(message: "Unexpected bridge response")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
while true {
|
||||
guard let next = try await self.receiveLine() else { break }
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||
|
||||
switch nextBase.type {
|
||||
case "res":
|
||||
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
|
||||
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
|
||||
case "event":
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
|
||||
self.broadcastServerEvent(evt)
|
||||
|
||||
case "ping":
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
|
||||
case "invoke":
|
||||
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||
let res = await onInvoke(req)
|
||||
try await self.send(res)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await self.disconnect()
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async throws {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
}
|
||||
|
||||
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||
guard self.connection != nil else {
|
||||
throw NSError(domain: "Bridge", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
|
||||
await self.timeoutRPC(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginRPC(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
|
||||
if res.ok {
|
||||
let payload = res.payloadJSON ?? ""
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
|
||||
])
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
let code = res.error?.code ?? "UNAVAILABLE"
|
||||
let message = res.error?.message ?? "request failed"
|
||||
throw NSError(domain: "Bridge", code: 13, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(code): \(message)",
|
||||
])
|
||||
}
|
||||
|
||||
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
|
||||
let id = UUID()
|
||||
let session = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
self.serverEventSubscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await session.removeServerEventSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
self.canvasHostUrl = nil
|
||||
|
||||
let pending = self.pendingRPC.values
|
||||
self.pendingRPC.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
for (_, cont) in self.serverEventSubscribers {
|
||||
cont.finish()
|
||||
}
|
||||
self.serverEventSubscribers.removeAll()
|
||||
|
||||
self.state = .idle
|
||||
}
|
||||
|
||||
private func beginRPC(
|
||||
id: String,
|
||||
request: BridgeRPCRequest,
|
||||
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
|
||||
{
|
||||
self.pendingRPC[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failRPC(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRPC(id: String) async {
|
||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout",
|
||||
]))
|
||||
}
|
||||
|
||||
private func failRPC(id: String, error: Error) async {
|
||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
|
||||
for (_, cont) in self.serverEventSubscribers {
|
||||
cont.yield(evt)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeServerEventSubscriber(_ id: UUID) {
|
||||
self.serverEventSubscribers[id] = nil
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
guard let connection = self.connection else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
guard let connection = self.connection else { return Data() }
|
||||
return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: seconds,
|
||||
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
|
||||
operation: operation)
|
||||
}
|
||||
|
||||
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
||||
AsyncStream { continuation in
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
connection.stateUpdateHandler = nil
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
continuation.yield(state)
|
||||
switch state {
|
||||
case .ready, .cancelled, .failed, .waiting:
|
||||
continuation.finish()
|
||||
case .setup, .preparing:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForReady(
|
||||
_ stateStream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await self.withTimeout(seconds: timeoutSeconds) {
|
||||
for await state in stateStream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(error):
|
||||
throw error
|
||||
case let .waiting(error):
|
||||
throw error
|
||||
case .cancelled:
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection cancelled")
|
||||
case .setup, .preparing:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection ended")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum BridgeSettingsStore {
|
||||
private static let bridgeService = "com.clawdbot.bridge"
|
||||
private static let nodeService = "com.clawdbot.node"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
|
||||
private static let lastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
|
||||
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredBridgeStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredBridgeStableIDAccount = "lastDiscoveredStableID"
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
self.ensureStableInstanceID()
|
||||
self.ensurePreferredBridgeStableID()
|
||||
self.ensureLastDiscoveredBridgeStableID()
|
||||
}
|
||||
|
||||
static func loadStableInstanceID() -> String? {
|
||||
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveStableInstanceID(_ instanceId: String) {
|
||||
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
|
||||
}
|
||||
|
||||
static func loadPreferredBridgeStableID() -> String? {
|
||||
KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func savePreferredBridgeStableID(_ stableID: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
stableID,
|
||||
service: self.bridgeService,
|
||||
account: self.preferredBridgeStableIDAccount)
|
||||
}
|
||||
|
||||
static func loadLastDiscoveredBridgeStableID() -> String? {
|
||||
KeychainStore.loadString(service: self.bridgeService, account: self.lastDiscoveredBridgeStableIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveLastDiscoveredBridgeStableID(_ stableID: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
stableID,
|
||||
service: self.bridgeService,
|
||||
account: self.lastDiscoveredBridgeStableIDAccount)
|
||||
}
|
||||
|
||||
private static func ensureStableInstanceID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadStableInstanceID() == nil {
|
||||
self.saveStableInstanceID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
|
||||
return
|
||||
}
|
||||
|
||||
let fresh = UUID().uuidString
|
||||
self.saveStableInstanceID(fresh)
|
||||
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
|
||||
}
|
||||
|
||||
private static func ensurePreferredBridgeStableID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadPreferredBridgeStableID() == nil {
|
||||
self.savePreferredBridgeStableID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureLastDiscoveredBridgeStableID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadLastDiscoveredBridgeStableID() == nil {
|
||||
self.saveLastDiscoveredBridgeStableID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadLastDiscoveredBridgeStableID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.lastDiscoveredBridgeStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ actor CameraController {
|
||||
{
|
||||
let facing = params.facing ?? .front
|
||||
let format = params.format ?? .jpg
|
||||
// Default to a reasonable max width to keep bridge payload sizes manageable.
|
||||
// Default to a reasonable max width to keep gateway payload sizes manageable.
|
||||
// If you need the full-res photo, explicitly request a larger maxWidth.
|
||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = Self.clampQuality(params.quality)
|
||||
@@ -160,14 +160,14 @@ actor CameraController {
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let movURL = FileManager.default.temporaryDirectory
|
||||
let movURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||
let mp4URL = FileManager.default.temporaryDirectory
|
||||
let mp4URL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: movURL)
|
||||
try? FileManager.default.removeItem(at: mp4URL)
|
||||
try? FileManager().removeItem(at: movURL)
|
||||
try? FileManager().removeItem(at: mp4URL)
|
||||
}
|
||||
|
||||
var delegate: MovieFileDelegate?
|
||||
@@ -190,14 +190,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices.map { device in
|
||||
return Self.discoverVideoDevices().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
@@ -232,7 +225,7 @@ actor CameraController {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -252,6 +245,24 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
.builtInUltraWideCamera,
|
||||
.builtInTelephotoCamera,
|
||||
.builtInDualCamera,
|
||||
.builtInDualWideCamera,
|
||||
.builtInTripleCamera,
|
||||
.builtInTrueDepthCamera,
|
||||
.builtInLiDARDepthCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices
|
||||
}
|
||||
|
||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||
let q = quality ?? 0.9
|
||||
return min(1.0, max(0.05, q))
|
||||
@@ -259,7 +270,7 @@ actor CameraController {
|
||||
|
||||
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 3000
|
||||
// Keep clips short by default; avoid huge base64 payloads on the bridge.
|
||||
// Keep clips short by default; avoid huge base64 payloads on the gateway.
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSheet: View {
|
||||
@@ -6,8 +7,8 @@ struct ChatSheet: View {
|
||||
@State private var viewModel: ClawdbotChatViewModel
|
||||
private let userAccent: Color?
|
||||
|
||||
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
self._viewModel = State(
|
||||
initialValue: ClawdbotChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
private let bridge: BridgeSession
|
||||
struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
init(bridge: BridgeSession) {
|
||||
self.bridge = bridge
|
||||
init(gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
@@ -16,7 +17,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse {
|
||||
@@ -27,7 +28,7 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
|
||||
@@ -35,14 +36,14 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(ClawdbotChatHistoryPayload.self, from: res)
|
||||
}
|
||||
|
||||
@@ -71,20 +72,20 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
return try JSONDecoder().decode(ClawdbotChatSendResponse.self, from: res)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
return (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: res))?.ok ?? true
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
let stream = await self.bridge.subscribeServerEvents()
|
||||
let stream = await self.gateway.subscribeServerEvents()
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
switch evt.event {
|
||||
@@ -93,18 +94,26 @@ struct IOSBridgeChatTransport: ClawdbotChatTransport, Sendable {
|
||||
case "seqGap":
|
||||
continuation.yield(.seqGap)
|
||||
case "health":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
let ok = (try? JSONDecoder().decode(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
guard let payload = evt.payload else { break }
|
||||
let ok = (try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: ClawdbotGatewayHealthOK.self))?.ok ?? true
|
||||
continuation.yield(.health(ok: ok))
|
||||
case "chat":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
if let payload = try? JSONDecoder().decode(ClawdbotChatEventPayload.self, from: data) {
|
||||
continuation.yield(.chat(payload))
|
||||
guard let payload = evt.payload else { break }
|
||||
if let chatPayload = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: ClawdbotChatEventPayload.self)
|
||||
{
|
||||
continuation.yield(.chat(chatPayload))
|
||||
}
|
||||
case "agent":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
|
||||
if let payload = try? JSONDecoder().decode(ClawdbotAgentEventPayload.self, from: data) {
|
||||
continuation.yield(.agent(payload))
|
||||
guard let payload = evt.payload else { break }
|
||||
if let agentPayload = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: ClawdbotAgentEventPayload.self)
|
||||
{
|
||||
continuation.yield(.agent(agentPayload))
|
||||
}
|
||||
default:
|
||||
break
|
||||
@@ -3,14 +3,14 @@ import SwiftUI
|
||||
@main
|
||||
struct ClawdbotApp: App {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var bridgeController: BridgeConnectionController
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
_appModel = State(initialValue: appModel)
|
||||
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -18,13 +18,13 @@ struct ClawdbotApp: App {
|
||||
RootCanvas()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.bridgeController)
|
||||
.environment(self.gatewayController)
|
||||
.onOpenURL { url in
|
||||
Task { await self.appModel.handleDeepLink(url: url) }
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.appModel.setScenePhase(newValue)
|
||||
self.bridgeController.setScenePhase(newValue)
|
||||
self.gatewayController.setScenePhase(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,39 +6,23 @@ import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
extension BridgeClient: BridgePairingClient {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BridgeConnectionController {
|
||||
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
||||
final class GatewayConnectionController {
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
private(set) var discoveryStatusText: String = "Idle"
|
||||
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
|
||||
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
||||
|
||||
private let discovery = BridgeDiscoveryModel()
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
|
||||
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
|
||||
|
||||
init(
|
||||
appModel: NodeAppModel,
|
||||
startDiscovery: Bool = true,
|
||||
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
|
||||
{
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
self.bridgeClientFactory = bridgeClientFactory
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let defaults = UserDefaults.standard
|
||||
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
|
||||
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
|
||||
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
@@ -63,18 +47,61 @@ final class BridgeConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let stableID = self.manualStableID(host: host, port: port)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newBridges = self.discovery.bridges
|
||||
self.bridges = newBridges
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
self.discoveryStatusText = self.discovery.statusText
|
||||
self.discoveryDebugLog = self.discovery.debugLog
|
||||
self.updateLastDiscoveredBridge(from: newBridges)
|
||||
self.updateLastDiscoveredGateway(from: newGateways)
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.bridges
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
@@ -89,134 +116,178 @@ final class BridgeConnectionController {
|
||||
private func maybeAutoConnect() {
|
||||
guard !self.didAutoConnect else { return }
|
||||
guard let appModel = self.appModel else { return }
|
||||
guard appModel.bridgeServerName == nil else { return }
|
||||
guard appModel.gatewayServerName == nil else { return }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
|
||||
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
||||
|
||||
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !instanceId.isEmpty else { return }
|
||||
|
||||
let token = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !token.isEmpty else { return }
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
|
||||
if manualEnabled {
|
||||
let manualHost = defaults.string(forKey: "bridge.manual.host")?
|
||||
let manualHost = defaults.string(forKey: "gateway.manual.host")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !manualHost.isEmpty else { return }
|
||||
|
||||
let manualPort = defaults.integer(forKey: "bridge.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18790
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return }
|
||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||
guard let targetStableID = candidates.first(where: { id in
|
||||
self.bridges.contains(where: { $0.stableID == id })
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) else { return }
|
||||
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
let defaults = UserDefaults.standard
|
||||
let preferred = defaults.string(forKey: "bridge.preferredStableID")?
|
||||
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let existingLast = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
||||
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
||||
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
||||
guard let first = bridges.first else { return }
|
||||
guard let first = gateways.first else { return }
|
||||
|
||||
defaults.set(first.stableID, forKey: "bridge.lastDiscoveredStableID")
|
||||
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(first.stableID)
|
||||
}
|
||||
|
||||
private func makeHello(token: String) -> BridgeHello {
|
||||
let defaults = UserDefaults.standard
|
||||
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
|
||||
return BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
}
|
||||
|
||||
private func keychainAccount(instanceId: String) -> String {
|
||||
"bridge-token.\(instanceId)"
|
||||
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
|
||||
}
|
||||
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
url: URL,
|
||||
gatewayStableID: String,
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
password: String?)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions()
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let hello = self.makeHello(token: token)
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
}
|
||||
})
|
||||
let resolvedToken = refreshed.isEmpty ? token : refreshed
|
||||
if !refreshed.isEmpty, refreshed != token {
|
||||
_ = KeychainStore.saveString(
|
||||
refreshed,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
}
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
appModel.connectToGateway(
|
||||
url: url,
|
||||
gatewayStableID: gatewayStableID,
|
||||
tls: tls,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: connectOptions)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = port
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func manualStableID(host: String, port: Int) -> String {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: [:],
|
||||
clientId: "clawdbot-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -263,6 +334,11 @@ final class BridgeConnectionController {
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
ClawdbotScreenCommand.record.rawValue,
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
ClawdbotSystemCommand.which.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
@@ -318,11 +394,7 @@ final class BridgeConnectionController {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeConnectionController {
|
||||
func _test_makeHello(token: String) -> BridgeHello {
|
||||
self.makeHello(token: token)
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
self.resolvedDisplayName(defaults: defaults)
|
||||
}
|
||||
@@ -351,8 +423,8 @@ extension BridgeConnectionController {
|
||||
self.appVersion()
|
||||
}
|
||||
|
||||
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
self.bridges = bridges
|
||||
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
self.gateways = gateways
|
||||
}
|
||||
|
||||
func _test_triggerAutoConnect() {
|
||||
@@ -1,9 +1,9 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct BridgeDiscoveryDebugLogView: View {
|
||||
@Environment(BridgeConnectionController.self) private var bridgeController
|
||||
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
|
||||
struct GatewayDiscoveryDebugLogView: View {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -12,11 +12,11 @@ struct BridgeDiscoveryDebugLogView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.bridgeController.discoveryDebugLog.isEmpty {
|
||||
if self.gatewayController.discoveryDebugLog.isEmpty {
|
||||
Text("No log entries yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.bridgeController.discoveryDebugLog) { entry in
|
||||
ForEach(self.gatewayController.discoveryDebugLog) { entry in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Self.formatTime(entry.ts))
|
||||
.font(.caption)
|
||||
@@ -35,13 +35,13 @@ struct BridgeDiscoveryDebugLogView: View {
|
||||
Button("Copy") {
|
||||
UIPasteboard.general.string = self.formattedLog()
|
||||
}
|
||||
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
|
||||
.disabled(self.gatewayController.discoveryDebugLog.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedLog() -> String {
|
||||
self.bridgeController.discoveryDebugLog
|
||||
self.gatewayController.discoveryDebugLog
|
||||
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BridgeDiscoveryModel {
|
||||
final class GatewayDiscoveryModel {
|
||||
struct DebugLogEntry: Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var ts: Date
|
||||
var message: String
|
||||
}
|
||||
|
||||
struct DiscoveredBridge: Identifiable, Equatable {
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
@@ -21,17 +21,18 @@ final class BridgeDiscoveryModel {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
var bridges: [DiscoveredBridge] = []
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
private(set) var debugLog: [DebugLogEntry] = []
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var bridgesByDomain: [String: [DiscoveredBridge]] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var debugLoggingEnabled = false
|
||||
private var lastStableIDs = Set<String>()
|
||||
@@ -43,7 +44,7 @@ final class BridgeDiscoveryModel {
|
||||
self.debugLog = []
|
||||
} else if !wasEnabled {
|
||||
self.appendDebugLog("debug logging enabled")
|
||||
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
|
||||
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +52,11 @@ final class BridgeDiscoveryModel {
|
||||
if !self.browsers.isEmpty { return }
|
||||
self.appendDebugLog("start()")
|
||||
|
||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||
for domain in ClawdbotBonjour.gatewayServiceDomains {
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
||||
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain),
|
||||
using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
@@ -70,7 +71,7 @@ final class BridgeDiscoveryModel {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
switch result.endpoint {
|
||||
case let .service(name, _, _, _):
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
@@ -80,16 +81,17 @@ final class BridgeDiscoveryModel {
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
|
||||
return DiscoveredBridge(
|
||||
return DiscoveredGateway(
|
||||
name: prettyName,
|
||||
endpoint: result.endpoint,
|
||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
stableID: GatewayEndpointID.stableID(result.endpoint),
|
||||
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
||||
lanHost: Self.txtValue(txt, key: "lanHost"),
|
||||
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
@@ -97,12 +99,12 @@ final class BridgeDiscoveryModel {
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
self.recomputeBridges()
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.bridge-discovery.\(domain)"))
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.ios.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +114,14 @@ final class BridgeDiscoveryModel {
|
||||
browser.cancel()
|
||||
}
|
||||
self.browsers = [:]
|
||||
self.bridgesByDomain = [:]
|
||||
self.gatewaysByDomain = [:]
|
||||
self.statesByDomain = [:]
|
||||
self.bridges = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func recomputeBridges() {
|
||||
let next = self.bridgesByDomain.values
|
||||
private func recomputeGateways() {
|
||||
let next = self.gatewaysByDomain.values
|
||||
.flatMap(\.self)
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
@@ -130,7 +132,7 @@ final class BridgeDiscoveryModel {
|
||||
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
|
||||
}
|
||||
self.lastStableIDs = nextIDs
|
||||
self.bridges = next
|
||||
self.gateways = next
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
@@ -214,4 +216,9 @@ final class BridgeDiscoveryModel {
|
||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
226
apps/ios/Sources/Gateway/GatewaySettingsStore.swift
Normal file
226
apps/ios/Sources/Gateway/GatewaySettingsStore.swift
Normal file
@@ -0,0 +1,226 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "com.clawdbot.gateway"
|
||||
private static let legacyBridgeService = "com.clawdbot.bridge"
|
||||
private static let nodeService = "com.clawdbot.node"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
|
||||
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
|
||||
private static let manualHostDefaultsKey = "gateway.manual.host"
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
|
||||
private static let legacyPreferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
|
||||
private static let legacyLastDiscoveredBridgeStableIDDefaultsKey = "bridge.lastDiscoveredStableID"
|
||||
private static let legacyManualEnabledDefaultsKey = "bridge.manual.enabled"
|
||||
private static let legacyManualHostDefaultsKey = "bridge.manual.host"
|
||||
private static let legacyManualPortDefaultsKey = "bridge.manual.port"
|
||||
private static let legacyDiscoveryDebugLogsDefaultsKey = "bridge.discovery.debugLogs"
|
||||
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
self.ensureStableInstanceID()
|
||||
self.ensurePreferredGatewayStableID()
|
||||
self.ensureLastDiscoveredGatewayStableID()
|
||||
self.migrateLegacyDefaults()
|
||||
}
|
||||
|
||||
static func loadStableInstanceID() -> String? {
|
||||
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveStableInstanceID(_ instanceId: String) {
|
||||
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
|
||||
}
|
||||
|
||||
static func loadPreferredGatewayStableID() -> String? {
|
||||
KeychainStore.loadString(service: self.gatewayService, account: self.preferredGatewayStableIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func savePreferredGatewayStableID(_ stableID: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
stableID,
|
||||
service: self.gatewayService,
|
||||
account: self.preferredGatewayStableIDAccount)
|
||||
}
|
||||
|
||||
static func loadLastDiscoveredGatewayStableID() -> String? {
|
||||
KeychainStore.loadString(service: self.gatewayService, account: self.lastDiscoveredGatewayStableIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
stableID,
|
||||
service: self.gatewayService,
|
||||
account: self.lastDiscoveredGatewayStableIDAccount)
|
||||
}
|
||||
|
||||
static func loadGatewayToken(instanceId: String) -> String? {
|
||||
let account = self.gatewayTokenAccount(instanceId: instanceId)
|
||||
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token?.isEmpty == false { return token }
|
||||
|
||||
let legacyAccount = self.legacyBridgeTokenAccount(instanceId: instanceId)
|
||||
let legacy = KeychainStore.loadString(service: self.legacyBridgeService, account: legacyAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let legacy, !legacy.isEmpty {
|
||||
_ = KeychainStore.saveString(legacy, service: self.gatewayService, account: account)
|
||||
return legacy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveGatewayToken(_ token: String, instanceId: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
token,
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func loadGatewayPassword(instanceId: String) -> String? {
|
||||
KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveGatewayPassword(_ password: String, instanceId: String) {
|
||||
_ = KeychainStore.saveString(
|
||||
password,
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
private static func gatewayTokenAccount(instanceId: String) -> String {
|
||||
"gateway-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private static func legacyBridgeTokenAccount(instanceId: String) -> String {
|
||||
"bridge-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
||||
"gateway-password.\(instanceId)"
|
||||
}
|
||||
|
||||
private static func ensureStableInstanceID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadStableInstanceID() == nil {
|
||||
self.saveStableInstanceID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
|
||||
return
|
||||
}
|
||||
|
||||
let fresh = UUID().uuidString
|
||||
self.saveStableInstanceID(fresh)
|
||||
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
|
||||
}
|
||||
|
||||
private static func ensurePreferredGatewayStableID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadPreferredGatewayStableID() == nil {
|
||||
self.savePreferredGatewayStableID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureLastDiscoveredGatewayStableID() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
if self.loadLastDiscoveredGatewayStableID() == nil {
|
||||
self.saveLastDiscoveredGatewayStableID(existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty {
|
||||
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?.isEmpty != false,
|
||||
let legacy = defaults.string(forKey: self.legacyPreferredBridgeStableIDDefaultsKey),
|
||||
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
defaults.set(legacy, forKey: self.preferredGatewayStableIDDefaultsKey)
|
||||
self.savePreferredGatewayStableID(legacy)
|
||||
}
|
||||
|
||||
if defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?.isEmpty != false,
|
||||
let legacy = defaults.string(forKey: self.legacyLastDiscoveredBridgeStableIDDefaultsKey),
|
||||
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
defaults.set(legacy, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
self.saveLastDiscoveredGatewayStableID(legacy)
|
||||
}
|
||||
|
||||
if defaults.object(forKey: self.manualEnabledDefaultsKey) == nil,
|
||||
defaults.object(forKey: self.legacyManualEnabledDefaultsKey) != nil
|
||||
{
|
||||
defaults.set(
|
||||
defaults.bool(forKey: self.legacyManualEnabledDefaultsKey),
|
||||
forKey: self.manualEnabledDefaultsKey)
|
||||
}
|
||||
|
||||
if defaults.string(forKey: self.manualHostDefaultsKey)?.isEmpty != false,
|
||||
let legacy = defaults.string(forKey: self.legacyManualHostDefaultsKey),
|
||||
!legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
defaults.set(legacy, forKey: self.manualHostDefaultsKey)
|
||||
}
|
||||
|
||||
if defaults.integer(forKey: self.manualPortDefaultsKey) == 0,
|
||||
defaults.integer(forKey: self.legacyManualPortDefaultsKey) > 0
|
||||
{
|
||||
defaults.set(
|
||||
defaults.integer(forKey: self.legacyManualPortDefaultsKey),
|
||||
forKey: self.manualPortDefaultsKey)
|
||||
}
|
||||
|
||||
if defaults.object(forKey: self.discoveryDebugLogsDefaultsKey) == nil,
|
||||
defaults.object(forKey: self.legacyDiscoveryDebugLogsDefaultsKey) != nil
|
||||
{
|
||||
defaults.set(
|
||||
defaults.bool(forKey: self.legacyDiscoveryDebugLogsDefaultsKey),
|
||||
forKey: self.discoveryDebugLogsDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260123</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -29,12 +29,12 @@
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdbot-bridge._tcp</string>
|
||||
<string>_clawdbot-gw._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||
<string>Clawdbot discovers and connects to your Clawdbot gateway on the local network.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
|
||||
@@ -18,15 +18,15 @@ final class NodeAppModel {
|
||||
let screen = ScreenController()
|
||||
let camera = CameraController()
|
||||
private let screenRecorder = ScreenRecordService()
|
||||
var bridgeStatusText: String = "Offline"
|
||||
var bridgeServerName: String?
|
||||
var bridgeRemoteAddress: String?
|
||||
var connectedBridgeID: String?
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
var seamColorHex: String?
|
||||
var mainSessionKey: String = "main"
|
||||
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
private let gateway = GatewayNodeSession()
|
||||
private var gatewayTask: Task<Void, Never>?
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
@@ -34,7 +34,8 @@ final class NodeAppModel {
|
||||
private let locationService = LocationService()
|
||||
private var lastAutoA2uiURL: String?
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
private var gatewayConnected = false
|
||||
var gatewaySession: GatewayNodeSession { self.gateway }
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
@@ -54,7 +55,7 @@ final class NodeAppModel {
|
||||
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachBridge(self.bridge)
|
||||
self.talkMode.attachGateway(self.gateway)
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
self.talkMode.setEnabled(talkEnabled)
|
||||
|
||||
@@ -109,7 +110,7 @@ final class NodeAppModel {
|
||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = "main"
|
||||
let sessionKey = self.mainSessionKey
|
||||
|
||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: name,
|
||||
@@ -120,9 +121,9 @@ final class NodeAppModel {
|
||||
|
||||
let ok: Bool
|
||||
var errorText: String?
|
||||
if await !self.isBridgeConnected() {
|
||||
if await !self.isGatewayConnected() {
|
||||
ok = false
|
||||
errorText = "bridge not connected"
|
||||
errorText = "gateway not connected"
|
||||
} else {
|
||||
do {
|
||||
try await self.sendAgentRequest(link: AgentDeepLink(
|
||||
@@ -150,7 +151,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private func resolveA2UIHostURL() async -> String? {
|
||||
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
|
||||
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
|
||||
@@ -202,51 +203,70 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
hello: BridgeHello)
|
||||
func connectToGateway(
|
||||
url: URL,
|
||||
gatewayStableID: String,
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
password: String?,
|
||||
connectOptions: GatewayConnectOptions)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
|
||||
self.gatewayConnected = false
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
|
||||
self.bridgeTask = Task {
|
||||
self.gatewayTask = Task {
|
||||
var attempt = 0
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run {
|
||||
if attempt == 0 {
|
||||
self.bridgeStatusText = "Connecting…"
|
||||
self.gatewayStatusText = "Connecting…"
|
||||
} else {
|
||||
self.bridgeStatusText = "Reconnecting…"
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
}
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
try await self.gateway.connect(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: connectOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Connected"
|
||||
self.bridgeServerName = serverName
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
self.gatewayConnected = true
|
||||
}
|
||||
if let addr = await self.bridge.currentRemoteAddress() {
|
||||
if let addr = await self.gateway.currentRemoteAddress() {
|
||||
await MainActor.run {
|
||||
self.bridgeRemoteAddress = addr
|
||||
self.gatewayRemoteAddress = addr
|
||||
}
|
||||
}
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.startVoiceWakeSync()
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Disconnected"
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
}
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
return BridgeInvokeResponse(
|
||||
@@ -260,19 +280,16 @@ final class NodeAppModel {
|
||||
})
|
||||
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
attempt += 1
|
||||
let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
attempt = 0
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if Task.isCancelled { break }
|
||||
attempt += 1
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
@@ -281,32 +298,50 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Offline"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectBridge() {
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeTask = nil
|
||||
func disconnectGateway() {
|
||||
self.gatewayTask?.cancel()
|
||||
self.gatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
Task { await self.bridge.disconnect() }
|
||||
self.bridgeStatusText = "Offline"
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = nil
|
||||
Task { await self.gateway.disconnect() }
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionKey = "main"
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private func applyMainSessionKey(_ key: String?) {
|
||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if SessionKey.isCanonicalMainSessionKey(current) { return }
|
||||
if trimmed == current { return }
|
||||
self.mainSessionKey = trimmed
|
||||
self.talkMode.updateMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
@@ -326,7 +361,7 @@ final class NodeAppModel {
|
||||
|
||||
private func refreshBrandingFromGateway() async {
|
||||
do {
|
||||
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
@@ -335,7 +370,10 @@ final class NodeAppModel {
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionKey = mainKey
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(mainKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -354,7 +392,7 @@ final class NodeAppModel {
|
||||
else { return }
|
||||
|
||||
do {
|
||||
_ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
|
||||
_ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
@@ -367,12 +405,14 @@ final class NodeAppModel {
|
||||
|
||||
await self.refreshWakeWordsFromGateway()
|
||||
|
||||
let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200)
|
||||
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "voicewake.changed" else { continue }
|
||||
guard let payloadJSON = evt.payloadJSON else { continue }
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue }
|
||||
guard let payload = evt.payload else { continue }
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
}
|
||||
}
|
||||
@@ -380,7 +420,7 @@ final class NodeAppModel {
|
||||
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
@@ -389,6 +429,11 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
if await !self.isGatewayConnected() {
|
||||
throw NSError(domain: "Gateway", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway not connected",
|
||||
])
|
||||
}
|
||||
struct Payload: Codable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
@@ -400,7 +445,7 @@ final class NodeAppModel {
|
||||
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
func handleDeepLink(url: URL) async {
|
||||
@@ -421,8 +466,8 @@ final class NodeAppModel {
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isBridgeConnected() else {
|
||||
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +486,7 @@ final class NodeAppModel {
|
||||
])
|
||||
}
|
||||
|
||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||
// iOS gateway forwards to the gateway; no local auth prompts here.
|
||||
// (Key-based unattended auth is handled on macOS for clawdbot:// links.)
|
||||
let data = try JSONEncoder().encode(link)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
@@ -449,12 +494,11 @@ final class NodeAppModel {
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
await self.gateway.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
private func isBridgeConnected() async -> Bool {
|
||||
if case .connected = await self.bridge.state { return true }
|
||||
return false
|
||||
private func isGatewayConnected() async -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -793,7 +837,7 @@ final class NodeAppModel {
|
||||
fps: params.fps,
|
||||
includeAudio: params.includeAudio,
|
||||
outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||
defer { try? FileManager().removeItem(atPath: path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
struct Payload: Codable {
|
||||
var format: String
|
||||
@@ -813,26 +857,29 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func locationMode() -> ClawdbotLocationMode {
|
||||
}
|
||||
|
||||
private extension NodeAppModel {
|
||||
func locationMode() -> ClawdbotLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
private func isLocationPreciseEnabled() -> Bool {
|
||||
func isLocationPreciseEnabled() -> Bool {
|
||||
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(obj)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||
@@ -842,17 +889,17 @@ final class NodeAppModel {
|
||||
return json
|
||||
}
|
||||
|
||||
private func isCameraEnabled() -> Bool {
|
||||
func isCameraEnabled() -> Bool {
|
||||
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
}
|
||||
|
||||
private func triggerCameraFlash() {
|
||||
func triggerCameraFlash() {
|
||||
self.cameraFlashNonce &+= 1
|
||||
}
|
||||
|
||||
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.cameraHUDDismissTask?.cancel()
|
||||
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
|
||||
@@ -29,7 +29,7 @@ struct RootCanvas: View {
|
||||
ZStack {
|
||||
CanvasContent(
|
||||
systemColorScheme: self.systemColorScheme,
|
||||
bridgeStatus: self.bridgeStatus,
|
||||
gatewayStatus: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
voiceWakeToastText: self.voiceWakeToastText,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
@@ -52,7 +52,7 @@ struct RootCanvas: View {
|
||||
SettingsTab()
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
bridge: self.appModel.bridgeSession,
|
||||
gateway: self.appModel.gatewaySession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
userAccent: self.appModel.seamColor)
|
||||
}
|
||||
@@ -62,9 +62,9 @@ struct RootCanvas: View {
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.bridgeRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -91,10 +91,10 @@ struct RootCanvas: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bridgeStatus: StatusPill.BridgeState {
|
||||
if self.appModel.bridgeServerName != nil { return .connected }
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
@@ -115,8 +115,8 @@ struct RootCanvas: View {
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.bridgeServerName ?? self.appModel.bridgeRemoteAddress
|
||||
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ private struct CanvasContent: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
var systemColorScheme: ColorScheme
|
||||
var bridgeStatus: StatusPill.BridgeState
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var voiceWakeToastText: String?
|
||||
var cameraHUDText: String?
|
||||
@@ -177,7 +177,7 @@ private struct CanvasContent: View {
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
bridge: self.bridgeStatus,
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
@@ -208,15 +208,15 @@ private struct CanvasContent: View {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bridgeLower = bridgeStatus.lowercased()
|
||||
if bridgeLower.contains("repair") {
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RootTabs: View {
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
bridge: self.bridgeStatus,
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
onTap: { self.selectedTab = 2 })
|
||||
@@ -64,10 +64,10 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var bridgeStatus: StatusPill.BridgeState {
|
||||
if self.appModel.bridgeServerName != nil { return .connected }
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
@@ -90,15 +90,15 @@ struct RootTabs: View {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let bridgeStatus = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let bridgeLower = bridgeStatus.lowercased()
|
||||
if bridgeLower.contains("repair") {
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if bridgeLower.contains("approval") || bridgeLower.contains("pairing") {
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
|
||||
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
let includeAudio = includeAudio ?? true
|
||||
|
||||
let outURL = self.makeOutputURL(outPath: outPath)
|
||||
try? FileManager.default.removeItem(at: outURL)
|
||||
try? FileManager().removeItem(at: outURL)
|
||||
|
||||
return RecordConfig(
|
||||
durationMs: durationMs,
|
||||
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||
}
|
||||
|
||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||
{
|
||||
{ sample, type, error in
|
||||
let sampleBox = UncheckedSendableBox(value: sample)
|
||||
// ReplayKit can call the capture handler on a background queue.
|
||||
// Serialize writes to avoid queue asserts.
|
||||
recordQueue.async {
|
||||
let sample = sampleBox.value
|
||||
if let error {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
|
||||
@@ -5,4 +5,11 @@ enum SessionKey {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed == "global" { return true }
|
||||
return trimmed.hasPrefix("agent:")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ extension ConnectStatusStore: @unchecked Sendable {}
|
||||
struct SettingsTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@@ -26,17 +26,20 @@ struct SettingsTab: View {
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = ""
|
||||
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
|
||||
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
|
||||
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
|
||||
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@State private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingBridgeID: String?
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -61,12 +64,12 @@ struct SettingsTab: View {
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
}
|
||||
|
||||
Section("Bridge") {
|
||||
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||
if let serverName = self.appModel.bridgeServerName {
|
||||
Section("Gateway") {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
if let addr = self.appModel.bridgeRemoteAddress {
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
let parts = Self.parseHostPort(from: addr)
|
||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||
LabeledContent("Address") {
|
||||
@@ -96,12 +99,12 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectBridge()
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
|
||||
self.bridgeList(showing: .availableOnly)
|
||||
self.gatewayList(showing: .availableOnly)
|
||||
} else {
|
||||
self.bridgeList(showing: .all)
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
if let text = self.connectStatus.text {
|
||||
@@ -111,19 +114,21 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled)
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
|
||||
TextField("Host", text: self.$manualBridgeHost)
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", value: self.$manualBridgePort, format: .number)
|
||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingBridgeID == "manual" {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
@@ -133,26 +138,32 @@ struct SettingsTab: View {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingBridgeID != nil || self.manualBridgeHost
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535)
|
||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "The bridge runs on the gateway (default port 18790).")
|
||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
BridgeDiscoveryDebugLogView()
|
||||
GatewayDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +190,7 @@ struct SettingsTab: View {
|
||||
|
||||
Section("Camera") {
|
||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||
Text("Allows the bridge to request photos or short video clips (foreground only).")
|
||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -221,13 +232,30 @@ struct SettingsTab: View {
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
}
|
||||
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.connectStatus.text = nil
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
@@ -248,14 +276,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||
if self.bridgeController.bridges.isEmpty {
|
||||
Text("No bridges found yet.")
|
||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
let connectedID = self.appModel.connectedBridgeID
|
||||
let rows = self.bridgeController.bridges.filter { bridge in
|
||||
let isConnected = bridge.stableID == connectedID
|
||||
let connectedID = self.appModel.connectedGatewayID
|
||||
let rows = self.gatewayController.gateways.filter { gateway in
|
||||
let isConnected = gateway.stableID == connectedID
|
||||
switch showing {
|
||||
case .all:
|
||||
return true
|
||||
@@ -265,14 +293,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
if rows.isEmpty, showing == .availableOnly {
|
||||
Text("No other bridges found.")
|
||||
Text("No other gateways found.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(rows) { bridge in
|
||||
ForEach(rows) { gateway in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(bridge.name)
|
||||
let detailLines = self.bridgeDetailLines(bridge)
|
||||
Text(gateway.name)
|
||||
let detailLines = self.gatewayDetailLines(gateway)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
@@ -282,31 +310,27 @@ struct SettingsTab: View {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await self.connect(bridge) }
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingBridgeID == bridge.id {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingBridgeID != nil)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BridgeListMode: Equatable {
|
||||
private enum GatewayListMode: Equatable {
|
||||
case all
|
||||
case availableOnly
|
||||
}
|
||||
|
||||
private func keychainAccount() -> String {
|
||||
"bridge-token.\(self.instanceId)"
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
@@ -341,180 +365,37 @@ struct SettingsTab: View {
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.manualGatewayEnabled = false
|
||||
self.preferredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
self.lastDiscoveredGatewayStableID = gateway.stableID
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
let cameraEnabled =
|
||||
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
||||
? true
|
||||
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
if cameraEnabled { caps.append(ClawdbotCapability.camera.rawValue) }
|
||||
|
||||
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
||||
if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) }
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentCommands() -> [String] {
|
||||
var commands: [String] = [
|
||||
ClawdbotCanvasCommand.present.rawValue,
|
||||
ClawdbotCanvasCommand.hide.rawValue,
|
||||
ClawdbotCanvasCommand.navigate.rawValue,
|
||||
ClawdbotCanvasCommand.evalJS.rawValue,
|
||||
ClawdbotCanvasCommand.snapshot.rawValue,
|
||||
ClawdbotCanvasA2UICommand.push.rawValue,
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
ClawdbotScreenCommand.record.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
if caps.contains(ClawdbotCapability.camera.rawValue) {
|
||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||
commands.append(ClawdbotCameraCommand.snap.rawValue)
|
||||
commands.append(ClawdbotCameraCommand.clip.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||
self.connectingBridgeID = bridge.id
|
||||
self.manualBridgeEnabled = false
|
||||
self.preferredBridgeStableID = bridge.stableID
|
||||
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
||||
self.lastDiscoveredBridgeStableID = bridge.stableID
|
||||
BridgeSettingsStore.saveLastDiscoveredBridgeStableID(bridge.stableID)
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||
existing :
|
||||
nil
|
||||
|
||||
let hello = BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existingToken,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
}
|
||||
})
|
||||
|
||||
if !token.isEmpty, token != existingToken {
|
||||
_ = KeychainStore.saveString(
|
||||
token,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands()))
|
||||
|
||||
} catch {
|
||||
self.connectStatus.text = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatus.text = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else {
|
||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
self.connectingBridgeID = "manual"
|
||||
self.manualBridgeEnabled = true
|
||||
defer { self.connectingBridgeID = nil }
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||
existing :
|
||||
nil
|
||||
|
||||
let hello = BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: existingToken,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
}
|
||||
})
|
||||
|
||||
if !token.isEmpty, token != existingToken {
|
||||
_ = KeychainStore.saveString(
|
||||
token,
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount())
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
token: token,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
deviceFamily: self.deviceFamily(),
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands()))
|
||||
|
||||
} catch {
|
||||
self.connectStatus.text = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS)
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
@@ -563,23 +444,21 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
|
||||
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
|
||||
let gatewayPort = bridge.gatewayPort
|
||||
let bridgePort = bridge.bridgePort
|
||||
let canvasPort = bridge.canvasPort
|
||||
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
|
||||
let gatewayPort = gateway.gatewayPort
|
||||
let canvasPort = gateway.canvasPort
|
||||
if gatewayPort != nil || canvasPort != nil {
|
||||
let gw = gatewayPort.map(String.init) ?? "—"
|
||||
let br = bridgePort.map(String.init) ?? "—"
|
||||
let canvas = canvasPort.map(String.init) ?? "—"
|
||||
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
|
||||
lines.append("Ports: gateway \(gw) · canvas \(canvas)")
|
||||
}
|
||||
|
||||
if lines.isEmpty {
|
||||
lines.append(bridge.debugID)
|
||||
lines.append(gateway.debugID)
|
||||
}
|
||||
|
||||
return lines
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct VoiceWakeWordsSettingsView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||
@FocusState private var focusedTriggerIndex: Int?
|
||||
@State private var syncTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
TextField("Wake word", text: self.binding(for: index))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused(self.$focusedTriggerIndex, equals: index)
|
||||
.onSubmit {
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
}
|
||||
.onDelete(perform: self.removeWords)
|
||||
|
||||
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
.onAppear {
|
||||
if self.triggerWords.isEmpty {
|
||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.triggerWords) { _, newValue in
|
||||
// Keep local voice wake responsive even if bridge isn't connected yet.
|
||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
||||
|
||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
||||
self.syncTask?.cancel()
|
||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||
await appModel?.setGlobalWakeWords(snapshot)
|
||||
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
||||
guard oldValue != nil, oldValue != newValue else { return }
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
guard self.focusedTriggerIndex == nil else { return }
|
||||
let updated = VoiceWakePreferences.loadTriggerWords()
|
||||
if updated != self.triggerWords {
|
||||
self.triggerWords = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
if self.triggerWords.isEmpty {
|
||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||
}
|
||||
self.commitTriggerWords()
|
||||
}
|
||||
|
||||
private func binding(for index: Int) -> Binding<String> {
|
||||
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
|
||||
self.triggerWords[index] = newValue
|
||||
})
|
||||
}
|
||||
|
||||
private func commitTriggerWords() {
|
||||
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
|
||||
|
||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
||||
self.syncTask?.cancel()
|
||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||
await appModel?.setGlobalWakeWords(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
enum BridgeState: Equatable {
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
case connecting
|
||||
case error
|
||||
@@ -34,7 +34,7 @@ struct StatusPill: View {
|
||||
var tint: Color?
|
||||
}
|
||||
|
||||
var bridge: BridgeState
|
||||
var gateway: GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: Activity?
|
||||
var brighten: Bool = false
|
||||
@@ -47,12 +47,12 @@ struct StatusPill: View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.bridge.color)
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.bridge.title)
|
||||
Text(self.gateway.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
@@ -95,26 +95,26 @@ struct StatusPill: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) }
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.bridge) { _, newValue in
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.bridge, scenePhase: newValue)
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let activity {
|
||||
return "\(self.bridge.title), \(activity.title)"
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {
|
||||
guard bridge == .connecting, scenePhase == .active else {
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
|
||||
guard gateway == .connecting, scenePhase == .active else {
|
||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFAudio
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
@@ -42,15 +43,22 @@ final class TalkModeManager: NSObject {
|
||||
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
|
||||
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
|
||||
|
||||
private var bridge: BridgeSession?
|
||||
private var gateway: GatewayNodeSession?
|
||||
private let silenceWindow: TimeInterval = 0.7
|
||||
|
||||
private var chatSubscribedSessionKeys = Set<String>()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
|
||||
|
||||
func attachBridge(_ bridge: BridgeSession) {
|
||||
self.bridge = bridge
|
||||
func attachGateway(_ gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String?) {
|
||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
@@ -124,6 +132,12 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
self.speechRecognizer = SFSpeechRecognizer()
|
||||
guard let recognizer = self.speechRecognizer else {
|
||||
@@ -138,6 +152,11 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
let input = self.audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||
])
|
||||
}
|
||||
input.removeTap(onBus: 0)
|
||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||
@@ -225,9 +244,9 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
await self.reloadConfig()
|
||||
let prompt = self.buildPrompt(transcript: transcript)
|
||||
guard let bridge else {
|
||||
self.statusText = "Bridge not connected"
|
||||
self.logger.warning("finalize: bridge not connected")
|
||||
guard let gateway else {
|
||||
self.statusText = "Gateway not connected"
|
||||
self.logger.warning("finalize: gateway not connected")
|
||||
await self.start()
|
||||
return
|
||||
}
|
||||
@@ -238,9 +257,9 @@ final class TalkModeManager: NSObject {
|
||||
await self.subscribeChatIfNeeded(sessionKey: sessionKey)
|
||||
self.logger.info(
|
||||
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||
let runId = try await self.sendChat(prompt, bridge: bridge)
|
||||
let runId = try await self.sendChat(prompt, gateway: gateway)
|
||||
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
|
||||
let completion = await self.waitForChatCompletion(runId: runId, bridge: bridge, timeoutSeconds: 120)
|
||||
let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
|
||||
if completion == .timeout {
|
||||
self.logger.warning(
|
||||
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||
@@ -257,7 +276,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
guard let assistantText = try await self.waitForAssistantText(
|
||||
bridge: bridge,
|
||||
gateway: gateway,
|
||||
since: startedAt,
|
||||
timeoutSeconds: completion == .final ? 12 : 25)
|
||||
else {
|
||||
@@ -279,31 +298,22 @@ final class TalkModeManager: NSObject {
|
||||
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||
|
||||
do {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
try await bridge.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
} catch {
|
||||
let err = error.localizedDescription
|
||||
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
|
||||
}
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
}
|
||||
|
||||
private func unsubscribeAllChats() async {
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
let keys = self.chatSubscribedSessionKeys
|
||||
self.chatSubscribedSessionKeys.removeAll()
|
||||
for key in keys {
|
||||
do {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
try await bridge.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +339,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendChat(_ message: String, bridge: BridgeSession) async throws -> String {
|
||||
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
|
||||
struct SendResponse: Decodable { let runId: String }
|
||||
let payload: [String: Any] = [
|
||||
"sessionKey": self.mainSessionKey,
|
||||
@@ -345,26 +355,27 @@ final class TalkModeManager: NSObject {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
|
||||
}
|
||||
let res = try await bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||
return decoded.runId
|
||||
}
|
||||
|
||||
private func waitForChatCompletion(
|
||||
runId: String,
|
||||
bridge: BridgeSession,
|
||||
gateway: GatewayNodeSession,
|
||||
timeoutSeconds: Int = 120) async -> ChatCompletionState
|
||||
{
|
||||
let stream = await bridge.subscribeServerEvents(bufferingNewest: 200)
|
||||
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
return await withTaskGroup(of: ChatCompletionState.self) { group in
|
||||
group.addTask { [runId] in
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return .timeout }
|
||||
guard evt.event == "chat", let payload = evt.payloadJSON else { continue }
|
||||
guard let data = payload.data(using: .utf8) else { continue }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
||||
if (json["runId"] as? String) != runId { continue }
|
||||
if let state = json["state"] as? String {
|
||||
guard evt.event == "chat", let payload = evt.payload else { continue }
|
||||
guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else {
|
||||
continue
|
||||
}
|
||||
guard chatEvent.runid == runId else { continue }
|
||||
if let state = chatEvent.state.value as? String {
|
||||
switch state {
|
||||
case "final": return .final
|
||||
case "aborted": return .aborted
|
||||
@@ -386,13 +397,13 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func waitForAssistantText(
|
||||
bridge: BridgeSession,
|
||||
gateway: GatewayNodeSession,
|
||||
since: Double,
|
||||
timeoutSeconds: Int) async throws -> String?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
while Date() < deadline {
|
||||
if let text = try await self.fetchLatestAssistantText(bridge: bridge, since: since) {
|
||||
if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -400,8 +411,8 @@ final class TalkModeManager: NSObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func fetchLatestAssistantText(bridge: BridgeSession, since: Double? = nil) async throws -> String? {
|
||||
let res = try await bridge.request(
|
||||
private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? {
|
||||
let res = try await gateway.request(
|
||||
method: "chat.history",
|
||||
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",
|
||||
timeoutSeconds: 15)
|
||||
@@ -642,14 +653,17 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func reloadConfig() async {
|
||||
guard let bridge else { return }
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let session = config["session"] as? [String: Any]
|
||||
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
||||
self.mainSessionKey = mainKey
|
||||
}
|
||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
|
||||
@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
|
||||
|
||||
// Keep defaults aligned with the mac app.
|
||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||
static let maxWords = 32
|
||||
static let maxWordLength = 64
|
||||
|
||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
|
||||
let cleaned = words
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.prefix(Self.maxWords)
|
||||
.map { String($0.prefix(Self.maxWordLength)) }
|
||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
Sources/Bridge/BridgeClient.swift
|
||||
Sources/Bridge/BridgeConnectionController.swift
|
||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
||||
Sources/Bridge/BridgeEndpointID.swift
|
||||
Sources/Bridge/BridgeSession.swift
|
||||
Sources/Bridge/BridgeSettingsStore.swift
|
||||
Sources/Bridge/KeychainStore.swift
|
||||
Sources/Gateway/GatewayConnectionController.swift
|
||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSBridgeChatTransport.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/ClawdbotApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
@@ -26,7 +25,8 @@ Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct BridgeClientTests {
|
||||
private final class LineServer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "com.clawdbot.tests.bridge-client-server")
|
||||
private let listener: NWListener
|
||||
private var connection: NWConnection?
|
||||
private var buffer = Data()
|
||||
|
||||
init() throws {
|
||||
self.listener = try NWListener(using: .tcp, on: .any)
|
||||
}
|
||||
|
||||
func start() async throws -> NWEndpoint.Port {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { cont in
|
||||
self.listener.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = self.listener.port {
|
||||
cont.resume(returning: port)
|
||||
} else {
|
||||
cont.resume(
|
||||
throwing: NSError(domain: "LineServer", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "listener missing port",
|
||||
]))
|
||||
}
|
||||
case let .failed(err):
|
||||
cont.resume(throwing: err)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
self.listener.newConnectionHandler = { [weak self] conn in
|
||||
guard let self else { return }
|
||||
self.connection = conn
|
||||
conn.start(queue: self.queue)
|
||||
}
|
||||
|
||||
self.listener.start(queue: self.queue)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.listener.cancel()
|
||||
}
|
||||
|
||||
func waitForConnection(timeoutMs: Int = 2000) async throws -> NWConnection {
|
||||
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if let connection = self.connection { return connection }
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
throw NSError(domain: "LineServer", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "timed out waiting for connection",
|
||||
])
|
||||
}
|
||||
|
||||
func receiveLine(timeoutMs: Int = 2000) async throws -> Data? {
|
||||
let connection = try await self.waitForConnection(timeoutMs: timeoutMs)
|
||||
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
|
||||
|
||||
while Date() < deadline {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let line = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return Data(line)
|
||||
}
|
||||
|
||||
let chunk = try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<
|
||||
Data,
|
||||
Error,
|
||||
>) in
|
||||
connection
|
||||
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
|
||||
throw NSError(domain: "LineServer", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "timed out waiting for line",
|
||||
])
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) async throws {
|
||||
let connection = try await self.waitForConnection()
|
||||
var data = Data(line.utf8)
|
||||
data.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func helloOkReturnsExistingToken() async throws {
|
||||
let server = try LineServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
|
||||
let serverTask = Task {
|
||||
let line = try await server.receiveLine()
|
||||
#expect(line != nil)
|
||||
_ = try JSONDecoder().decode(BridgeHello.self, from: line ?? Data())
|
||||
try await server.sendLine(#"{"type":"hello-ok","serverName":"Test Gateway"}"#)
|
||||
}
|
||||
defer { serverTask.cancel() }
|
||||
|
||||
let client = BridgeClient()
|
||||
let token = try await client.pairAndHello(
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||
hello: BridgeHello(
|
||||
nodeId: "ios-node",
|
||||
displayName: "iOS",
|
||||
token: "existing-token",
|
||||
platform: "ios",
|
||||
version: "1"),
|
||||
onStatus: nil)
|
||||
|
||||
#expect(token == "existing-token")
|
||||
_ = try await serverTask.value
|
||||
}
|
||||
|
||||
@Test func notPairedTriggersPairRequestAndReturnsToken() async throws {
|
||||
let server = try LineServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
|
||||
let serverTask = Task {
|
||||
let helloLine = try await server.receiveLine()
|
||||
#expect(helloLine != nil)
|
||||
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
|
||||
try await server.sendLine(#"{"type":"error","code":"NOT_PAIRED","message":"not paired"}"#)
|
||||
|
||||
let pairLine = try await server.receiveLine()
|
||||
#expect(pairLine != nil)
|
||||
_ = try JSONDecoder().decode(BridgePairRequest.self, from: pairLine ?? Data())
|
||||
try await server.sendLine(#"{"type":"pair-ok","token":"paired-token"}"#)
|
||||
}
|
||||
defer { serverTask.cancel() }
|
||||
|
||||
let client = BridgeClient()
|
||||
let token = try await client.pairAndHello(
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
|
||||
onStatus: nil)
|
||||
|
||||
#expect(token == "paired-token")
|
||||
_ = try await serverTask.value
|
||||
}
|
||||
|
||||
@Test func unexpectedErrorIsSurfaced() async {
|
||||
do {
|
||||
let server = try LineServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
|
||||
let serverTask = Task {
|
||||
let helloLine = try await server.receiveLine()
|
||||
#expect(helloLine != nil)
|
||||
_ = try JSONDecoder().decode(BridgeHello.self, from: helloLine ?? Data())
|
||||
try await server.sendLine(#"{"type":"error","code":"NOPE","message":"nope"}"#)
|
||||
}
|
||||
defer { serverTask.cancel() }
|
||||
|
||||
let client = BridgeClient()
|
||||
_ = try await client.pairAndHello(
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: port),
|
||||
hello: BridgeHello(nodeId: "ios-node", displayName: "iOS", token: nil, platform: "ios", version: "1"),
|
||||
onStatus: nil)
|
||||
|
||||
Issue.record("Expected pairAndHello to throw for unexpected error code")
|
||||
} catch {
|
||||
#expect(error.localizedDescription.contains("NOPE"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Clawdbot
|
||||
|
||||
private struct KeychainEntry: Hashable {
|
||||
let service: String
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let bridgeService = "com.clawdbot.bridge"
|
||||
private let nodeService = "com.clawdbot.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
|
||||
private actor MockBridgePairingClient: BridgePairingClient {
|
||||
private(set) var lastToken: String?
|
||||
private let resultToken: String
|
||||
|
||||
init(resultToken: String) {
|
||||
self.resultToken = resultToken
|
||||
}
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
onStatus?("Testing…")
|
||||
return self.resultToken
|
||||
}
|
||||
}
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func withUserDefaults<T>(
|
||||
_ updates: [String: Any?],
|
||||
_ body: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await body()
|
||||
}
|
||||
|
||||
private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body: () throws -> T) rethrows -> T {
|
||||
var snapshot: [KeychainEntry: String?] = [:]
|
||||
for entry in updates.keys {
|
||||
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||
}
|
||||
for (entry, value) in updates {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (entry, value) in snapshot {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func withKeychainValues<T>(
|
||||
_ updates: [KeychainEntry: String?],
|
||||
_ body: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
var snapshot: [KeychainEntry: String?] = [:]
|
||||
for entry in updates.keys {
|
||||
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
|
||||
}
|
||||
for (entry, value) in updates {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (entry, value) in snapshot {
|
||||
if let value {
|
||||
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: entry.service, account: entry.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try await body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct BridgeConnectionControllerTests {
|
||||
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(defaults.string(forKey: displayKey) == resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func resolvedDisplayNamePreservesCustomValue() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([displayKey: "My iOS Node", "node.instanceId": "ios-test"]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(resolved == "My iOS Node")
|
||||
#expect(defaults.string(forKey: displayKey) == "My iOS Node")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"node.displayName": "Test Node",
|
||||
"camera.enabled": false,
|
||||
voiceWakeKey: true,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let hello = controller._test_makeHello(token: "token-123")
|
||||
|
||||
#expect(hello.nodeId == "ios-test")
|
||||
#expect(hello.displayName == "Test Node")
|
||||
#expect(hello.token == "token-123")
|
||||
|
||||
let caps = Set(hello.caps ?? [])
|
||||
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
|
||||
#expect(!caps.contains(ClawdbotCapability.camera.rawValue))
|
||||
|
||||
let commands = Set(hello.commands ?? [])
|
||||
#expect(commands.contains(ClawdbotCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(ClawdbotScreenCommand.record.rawValue))
|
||||
#expect(!commands.contains(ClawdbotCameraCommand.snap.rawValue))
|
||||
|
||||
#expect(!(hello.platform ?? "").isEmpty)
|
||||
#expect(!(hello.deviceFamily ?? "").isEmpty)
|
||||
#expect(!(hello.modelIdentifier ?? "").isEmpty)
|
||||
#expect(!(hello.version ?? "").isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloIncludesCameraCommandsWhenEnabled() {
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"node.displayName": "Test Node",
|
||||
"camera.enabled": true,
|
||||
VoiceWakePreferences.enabledKey: false,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let hello = controller._test_makeHello(token: "token-456")
|
||||
|
||||
let caps = Set(hello.caps ?? [])
|
||||
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
|
||||
|
||||
let commands = Set(hello.commands ?? [])
|
||||
#expect(commands.contains(ClawdbotCameraCommand.snap.rawValue))
|
||||
#expect(commands.contains(ClawdbotCameraCommand.clip.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
|
||||
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway",
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-debug",
|
||||
lanHost: "Mac.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
cliPath: nil)
|
||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||
let account = "bridge-token.ios-test"
|
||||
|
||||
await withKeychainValues([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||
]) {
|
||||
await withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||
"bridge.manual.enabled": false,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
bridgeClientFactory: { mock })
|
||||
controller._test_setBridges([bridge])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
for _ in 0..<20 {
|
||||
if appModel.connectedBridgeID == bridge.stableID { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
#expect(appModel.connectedBridgeID == bridge.stableID)
|
||||
let stored = KeychainStore.loadString(service: bridgeService, account: account)
|
||||
#expect(stored == "new-token")
|
||||
let lastToken = await mock.lastToken
|
||||
#expect(lastToken == "old-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func autoConnectPrefersPreferredBridgeOverLastDiscovered() async {
|
||||
let bridgeA = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway A",
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-a",
|
||||
lanHost: "MacA.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
cliPath: nil)
|
||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway B",
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 28790),
|
||||
stableID: "bridge-2",
|
||||
debugID: "bridge-b",
|
||||
lanHost: "MacB.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 28789,
|
||||
bridgePort: 28790,
|
||||
canvasPort: 28793,
|
||||
cliPath: nil)
|
||||
|
||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||
let account = "bridge-token.ios-test"
|
||||
|
||||
await withKeychainValues([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||
]) {
|
||||
await withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"bridge.preferredStableID": "bridge-2",
|
||||
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||
"bridge.manual.enabled": false,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
bridgeClientFactory: { mock })
|
||||
controller._test_setBridges([bridgeA, bridgeB])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
for _ in 0..<20 {
|
||||
if appModel.connectedBridgeID == bridgeB.stableID { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
#expect(appModel.connectedBridgeID == bridgeB.stableID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct BridgeSessionTests {
|
||||
@Test func initialStateIsIdle() async {
|
||||
let session = BridgeSession()
|
||||
#expect(await session.state == .idle)
|
||||
}
|
||||
|
||||
@Test func requestFailsWhenNotConnected() async {
|
||||
let session = BridgeSession()
|
||||
|
||||
do {
|
||||
_ = try await session.request(method: "health", paramsJSON: nil, timeoutSeconds: 1)
|
||||
Issue.record("Expected request to throw when not connected")
|
||||
} catch let error as NSError {
|
||||
#expect(error.domain == "Bridge")
|
||||
#expect(error.code == 11)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sendEventFailsWhenNotConnected() async {
|
||||
let session = BridgeSession()
|
||||
|
||||
do {
|
||||
try await session.sendEvent(event: "tick", payloadJSON: nil)
|
||||
Issue.record("Expected sendEvent to throw when not connected")
|
||||
} catch let error as NSError {
|
||||
#expect(error.domain == "Bridge")
|
||||
#expect(error.code == 10)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func disconnectFinishesServerEventStreams() async throws {
|
||||
let session = BridgeSession()
|
||||
let stream = await session.subscribeServerEvents(bufferingNewest: 1)
|
||||
|
||||
let consumer = Task { @Sendable in
|
||||
for await _ in stream {}
|
||||
}
|
||||
|
||||
await session.disconnect()
|
||||
|
||||
_ = await consumer.result
|
||||
#expect(await session.state == .idle)
|
||||
}
|
||||
}
|
||||
79
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal file
79
apps/ios/Tests/GatewayConnectionControllerTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Clawdbot
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
||||
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayKey = "node.displayName"
|
||||
|
||||
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let resolved = controller._test_resolvedDisplayName(defaults: defaults)
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(defaults.string(forKey: displayKey) == resolved)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func currentCapsReflectToggles() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"node.displayName": "Test Node",
|
||||
"camera.enabled": true,
|
||||
"location.enabledMode": ClawdbotLocationMode.always.rawValue,
|
||||
VoiceWakePreferences.enabledKey: true,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let caps = Set(controller._test_currentCaps())
|
||||
|
||||
#expect(caps.contains(ClawdbotCapability.canvas.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.screen.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.camera.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.location.rawValue))
|
||||
#expect(caps.contains(ClawdbotCapability.voiceWake.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func currentCommandsIncludeLocationWhenEnabled() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"location.enabledMode": ClawdbotLocationMode.whileUsing.rawValue,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let commands = Set(controller._test_currentCommands())
|
||||
|
||||
#expect(commands.contains(ClawdbotLocationCommand.get.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized) struct BridgeDiscoveryModelTests {
|
||||
@Suite(.serialized) struct GatewayDiscoveryModelTests {
|
||||
@Test @MainActor func debugLoggingCapturesLifecycleAndResets() {
|
||||
let model = BridgeDiscoveryModel()
|
||||
let model = GatewayDiscoveryModel()
|
||||
|
||||
#expect(model.debugLog.isEmpty)
|
||||
#expect(model.statusText == "Idle")
|
||||
@@ -13,7 +13,7 @@ import Testing
|
||||
|
||||
model.stop()
|
||||
#expect(model.statusText == "Stopped")
|
||||
#expect(model.bridges.isEmpty)
|
||||
#expect(model.gateways.isEmpty)
|
||||
#expect(model.debugLog.count >= 3)
|
||||
|
||||
model.setDebugLoggingEnabled(false)
|
||||
@@ -3,30 +3,30 @@ import Network
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct BridgeEndpointIDTests {
|
||||
@Suite struct GatewayEndpointIDTests {
|
||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Bridge \\032 Node\n",
|
||||
type: "_clawdbot-bridge._tcp",
|
||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
#expect(BridgeEndpointID.stableID(endpoint) == "_clawdbot-bridge._tcp|local.|Clawdbot Bridge Node")
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||
}
|
||||
|
||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242)
|
||||
#expect(BridgeEndpointID.stableID(endpoint) == String(describing: endpoint))
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint))
|
||||
}
|
||||
|
||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Bridge",
|
||||
type: "_clawdbot-bridge._tcp",
|
||||
name: "Clawdbot\\032Gateway",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
let pretty = BridgeEndpointID.prettyDescription(endpoint)
|
||||
let pretty = GatewayEndpointID.prettyDescription(endpoint)
|
||||
#expect(pretty == BonjourEscapes.decode(String(describing: endpoint)))
|
||||
#expect(!pretty.localizedCaseInsensitiveContains("\\032"))
|
||||
}
|
||||
@@ -7,11 +7,11 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let bridgeService = "com.clawdbot.bridge"
|
||||
private let gatewayService = "com.clawdbot.gateway"
|
||||
private let nodeService = "com.clawdbot.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -59,14 +59,14 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
applyKeychain(snapshot)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct BridgeSettingsStoreTests {
|
||||
@Suite(.serialized) struct GatewaySettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
@@ -76,29 +76,29 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"bridge.preferredStableID": "preferred-test",
|
||||
"bridge.lastDiscoveredStableID": "last-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: bridgeService, account: "lastDiscoveredStableID") == "last-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"bridge.preferredStableID",
|
||||
"bridge.lastDiscoveredStableID",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredBridgeEntry, lastBridgeEntry]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
@@ -108,20 +108,20 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"bridge.preferredStableID": nil,
|
||||
"bridge.lastDiscoveredStableID": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredBridgeEntry: "preferred-from-keychain",
|
||||
lastBridgeEntry: "last-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "bridge.lastDiscoveredStableID") == "last-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import ClawdbotKit
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct IOSBridgeChatTransportTests {
|
||||
@Test func requestsFailFastWhenBridgeNotConnected() async {
|
||||
let bridge = BridgeSession()
|
||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||
|
||||
do {
|
||||
try await transport.setActiveSessionKey("node-test")
|
||||
Issue.record("Expected setActiveSessionKey to throw when bridge not connected")
|
||||
} catch {}
|
||||
@Suite struct IOSGatewayChatTransportTests {
|
||||
@Test func requestsFailFastWhenGatewayNotConnected() async {
|
||||
let gateway = GatewayNodeSession()
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
|
||||
do {
|
||||
_ = try await transport.requestHistory(sessionKey: "node-test")
|
||||
Issue.record("Expected requestHistory to throw when bridge not connected")
|
||||
Issue.record("Expected requestHistory to throw when gateway not connected")
|
||||
} catch {}
|
||||
|
||||
do {
|
||||
@@ -23,11 +19,12 @@ import Testing
|
||||
thinking: "low",
|
||||
idempotencyKey: "idempotency",
|
||||
attachments: [])
|
||||
Issue.record("Expected sendMessage to throw when bridge not connected")
|
||||
Issue.record("Expected sendMessage to throw when gateway not connected")
|
||||
} catch {}
|
||||
|
||||
do {
|
||||
_ = try await transport.requestHealth(timeoutMs: 250)
|
||||
Issue.record("Expected requestHealth to throw when gateway not connected")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260123</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -159,7 +159,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "clawdbot://agent?message=hello")!
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Bridge not connected") == true)
|
||||
#expect(appModel.screen.errorText?.contains("Gateway not connected") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
|
||||
@@ -170,7 +170,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async {
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@@ -14,35 +15,35 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() {
|
||||
let root = StatusPill(bridge: .connecting, voiceWakeEnabled: true, brighten: true) {}
|
||||
let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
|
||||
let root = StatusPill(bridge: .disconnected, voiceWakeEnabled: false) {}
|
||||
let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = SettingsTab()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(bridgeController)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridgeController = BridgeConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(bridgeController)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
@@ -66,8 +67,8 @@ import UIKit
|
||||
|
||||
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let bridge = BridgeSession()
|
||||
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
||||
let gateway = GatewayNodeSession()
|
||||
let root = ChatSheet(gateway: gateway, sessionKey: "test")
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
_ = Self.host(root)
|
||||
|
||||
@@ -11,6 +11,18 @@ import Testing
|
||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||
}
|
||||
|
||||
@Test func sanitizeTriggerWordsLimitsWordLength() {
|
||||
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
|
||||
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
|
||||
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
|
||||
}
|
||||
|
||||
@Test func sanitizeTriggerWordsLimitsWordCount() {
|
||||
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
|
||||
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||
#expect(cleaned.count == VoiceWakePreferences.maxWords)
|
||||
}
|
||||
|
||||
@Test func displayStringUsesSanitizedWords() {
|
||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user