Compare commits
2685
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ecee84d1 | ||
|
|
d1ebc4489a | ||
|
|
a71efbe7de | ||
|
|
a7b8c0d963 | ||
|
|
55a07a0ef0 | ||
|
|
9899ba53a3 | ||
|
|
52458a5628 | ||
|
|
7abd6713c8 | ||
|
|
451174ca10 | ||
|
|
cdfbd6e7eb | ||
|
|
350e007a5c | ||
|
|
5e156135a1 | ||
|
|
b7ec9ae475 | ||
|
|
8a18af409d | ||
|
|
6a125b554b | ||
|
|
ce92fac983 | ||
|
|
341a224301 | ||
|
|
95cd153f33 | ||
|
|
0af89022ff | ||
|
|
27a8f3d061 | ||
|
|
72b34f7d03 | ||
|
|
73fa2e10bc | ||
|
|
4a6b33d799 | ||
|
|
145964c85e | ||
|
|
217b84f2ac | ||
|
|
1d6de24ab3 | ||
|
|
822def84d2 | ||
|
|
f313af75e9 | ||
|
|
591773715e | ||
|
|
dd6b9b510b | ||
|
|
6ae51ae3de | ||
|
|
00c3e98431 | ||
|
|
dd561f58d1 | ||
|
|
200dd634fb | ||
|
|
3bbdcaf87f | ||
|
|
abff5e3b1f | ||
|
|
40ee0f0672 | ||
|
|
9f8eeceae7 | ||
|
|
53baba71fa | ||
|
|
0f85080d81 | ||
|
|
72f8148080 | ||
|
|
be3da5b856 | ||
|
|
9a9b429f74 | ||
|
|
733e86516e | ||
|
|
1a00175eb7 | ||
|
|
77c76ca52f | ||
|
|
5de3395204 | ||
|
|
4e4655f607 | ||
|
|
48731f494b | ||
|
|
4fcd89c3d9 | ||
|
|
a4f433a1b1 | ||
|
|
84a7ee491b | ||
|
|
3043dd3a0c | ||
|
|
81f4a7cdb7 | ||
|
|
c2a74d6d2a | ||
|
|
861e1b33f5 | ||
|
|
0647d56555 | ||
|
|
ea6aea8532 | ||
|
|
6eca2edd79 | ||
|
|
d31dfbc565 | ||
|
|
1e0f776824 | ||
|
|
db36f0105d | ||
|
|
5377e2400a | ||
|
|
72c0aa63fb | ||
|
|
933bee220f | ||
|
|
bd2dabfa8f | ||
|
|
f11b352089 | ||
|
|
bb54e60179 | ||
|
|
e52bdaa2a2 | ||
|
|
b6301c719b | ||
|
|
6e16c0699a | ||
|
|
bf4ad295af | ||
|
|
7a80e8fe77 | ||
|
|
1ec3512925 | ||
|
|
772ada4308 | ||
|
|
7165c8a7e5 | ||
|
|
daa1460502 | ||
|
|
f47c7ac369 | ||
|
|
7199813969 | ||
|
|
87d5fa516d | ||
|
|
10340d2a3f | ||
|
|
508c4d362f | ||
|
|
f73b008251 | ||
|
|
c583e64bb7 | ||
|
|
9df63b008d | ||
|
|
3daecc092c | ||
|
|
4d42811ecf | ||
|
|
1bebcf8033 | ||
|
|
45c555a4bd | ||
|
|
5986a83e80 | ||
|
|
732de4acf0 | ||
|
|
7400c0946e | ||
|
|
14ee2b2c11 | ||
|
|
c3e1b8cfd9 | ||
|
|
67a67df35a | ||
|
|
0f0578b268 | ||
|
|
662208949f | ||
|
|
e41821342b | ||
|
|
d3458a4fc3 | ||
|
|
32c91bbb25 | ||
|
|
aee13507f9 | ||
|
|
61b67f6301 | ||
|
|
b86619bcd0 | ||
|
|
31b5b45581 | ||
|
|
33cdb16b9e | ||
|
|
53fd7a4473 | ||
|
|
10d56d31e9 | ||
|
|
3633c829ae | ||
|
|
6cda84432e | ||
|
|
b914eaa6fa | ||
|
|
988b67aa30 | ||
|
|
0ed5b82389 | ||
|
|
b417fe5727 | ||
|
|
fabad7aa7a | ||
|
|
3c54da952a | ||
|
|
2ef2646b31 | ||
|
|
82ad7e29a6 | ||
|
|
2290a3c8af | ||
|
|
d216cebff5 | ||
|
|
05bd345828 | ||
|
|
5eff541da8 | ||
|
|
598a27cc96 | ||
|
|
08ce608ae7 | ||
|
|
928631309e | ||
|
|
971b98c96d | ||
|
|
a72da30c9a | ||
|
|
f7eabcb2d9 | ||
|
|
ac36eba822 | ||
|
|
6160521f2f | ||
|
|
ca9b0dbc88 | ||
|
|
11c7e05f43 | ||
|
|
1781105438 | ||
|
|
632ca01fbf | ||
|
|
b8fd22bfd8 | ||
|
|
98a1deb129 | ||
|
|
0c38f2df2a | ||
|
|
6bab813bb3 | ||
|
|
d8201f8436 | ||
|
|
b28e4e95c2 | ||
|
|
a3865f1417 | ||
|
|
fb10bf5f75 | ||
|
|
a9eb31e8fe | ||
|
|
3ec5ce8349 | ||
|
|
c5d70019bb | ||
|
|
a35fb3a9b4 | ||
|
|
79403f9083 | ||
|
|
7a44c19362 | ||
|
|
11fc10ea47 | ||
|
|
7e4e9ecdea | ||
|
|
0c013a237f | ||
|
|
f85951bc65 | ||
|
|
12e27f9e5e | ||
|
|
7e9be3c28c | ||
|
|
3368fcf31e | ||
|
|
32877afe55 | ||
|
|
efe7eca726 | ||
|
|
72d1fa4da5 | ||
|
|
2042013360 | ||
|
|
f5189cc897 | ||
|
|
75a9cd83a0 | ||
|
|
5684e2d658 | ||
|
|
2d28fa34f5 | ||
|
|
ea7d967625 | ||
|
|
5dfb2b1128 | ||
|
|
cbc599a5b8 | ||
|
|
1354d0836f | ||
|
|
b313250638 | ||
|
|
e37c147ea9 | ||
|
|
feb4f9028d | ||
|
|
4804ce5678 | ||
|
|
001a342f20 | ||
|
|
fe040b84d9 | ||
|
|
0ac30afb29 | ||
|
|
59601eb99c | ||
|
|
9616f4b2b1 | ||
|
|
9dd613edf7 | ||
|
|
88ed58b3d0 | ||
|
|
fc54e905c0 | ||
|
|
d1b76cb1b2 | ||
|
|
2c92ccd66e | ||
|
|
a9ff03acaf | ||
|
|
281dc10b2f | ||
|
|
fd32fc8d8d | ||
|
|
47f4f59692 | ||
|
|
5cf1a9535e | ||
|
|
e93102b276 | ||
|
|
da57c314ef | ||
|
|
2676636316 | ||
|
|
f3a973dc9e | ||
|
|
f4a1190bdd | ||
|
|
118a6d7421 | ||
|
|
4541bb2716 | ||
|
|
505c4262c6 | ||
|
|
3104b088e4 | ||
|
|
f12f814816 | ||
|
|
3b0ad719c9 | ||
|
|
e368e56246 | ||
|
|
675420013d | ||
|
|
eaa69fb6b2 | ||
|
|
e0795cf18c | ||
|
|
8ed878e73c | ||
|
|
08b95411df | ||
|
|
460fafff7f | ||
|
|
7b4fa9e1a1 | ||
|
|
7e4ebb22a0 | ||
|
|
8b47315845 | ||
|
|
96a5e01878 | ||
|
|
5e36390a27 | ||
|
|
729a545173 | ||
|
|
488f5e2dac | ||
|
|
49e89cf3f1 | ||
|
|
43f6b9ef32 | ||
|
|
8e48cffe3b | ||
|
|
3ed01adabc | ||
|
|
4239de8060 | ||
|
|
cba37f99b6 | ||
|
|
74db53d939 | ||
|
|
2b34bf08da | ||
|
|
b92f70c52b | ||
|
|
34d2e1e2e8 | ||
|
|
5f82739e2b | ||
|
|
d79dc4d742 | ||
|
|
1d12a844c2 | ||
|
|
2d16450869 | ||
|
|
2a6248dad6 | ||
|
|
8b27c03472 | ||
|
|
baf3bea574 | ||
|
|
868b438e67 | ||
|
|
8989bd9fd7 | ||
|
|
a4f12babb7 | ||
|
|
97e06a8eb4 | ||
|
|
0de6e38ce9 | ||
|
|
314164fb8a | ||
|
|
8d925226cb | ||
|
|
f2eb2004aa | ||
|
|
bf37015c23 | ||
|
|
f489b6e7a5 | ||
|
|
921e5be8e6 | ||
|
|
a8bc974a2e | ||
|
|
100a022ab7 | ||
|
|
6b7484a885 | ||
|
|
8de40e0c10 | ||
|
|
9b3aef3567 | ||
|
|
25e52e19dc | ||
|
|
68806902ff | ||
|
|
ebf8649940 | ||
|
|
c93d02891a | ||
|
|
87be5c737c | ||
|
|
ad9d6f616d | ||
|
|
f57f892409 | ||
|
|
5ecb65cbbe | ||
|
|
1e04481aaf | ||
|
|
5f103e32bd | ||
|
|
fff9efe8a8 | ||
|
|
b135b3efb9 | ||
|
|
17e17f85ae | ||
|
|
ecef49605b | ||
|
|
7b72b35cca | ||
|
|
16420e5b31 | ||
|
|
55665246bb | ||
|
|
b9b862a380 | ||
|
|
0766c5e3cb | ||
|
|
8d4c6d41ab | ||
|
|
58d32d4542 | ||
|
|
6bad75827a | ||
|
|
2b3ddabe90 | ||
|
|
e92b480629 | ||
|
|
a53cdbf1b4 | ||
|
|
21a64a9957 | ||
|
|
d656db4d04 | ||
|
|
506b66a852 | ||
|
|
95f03d63ad | ||
|
|
7f8af736dd | ||
|
|
eaacebeecc | ||
|
|
fd4cff06ca | ||
|
|
b50df6eb1d | ||
|
|
fa16304e4f | ||
|
|
eda74d3a55 | ||
|
|
25762c0ac6 | ||
|
|
2d7289bcad | ||
|
|
2d1d5d603d | ||
|
|
94206cf10f | ||
|
|
dc2521a1cf | ||
|
|
30b5955f22 | ||
|
|
4267a1b87d | ||
|
|
eb44ae76f1 | ||
|
|
bd3d18f660 | ||
|
|
8bd5f1b9f2 | ||
|
|
71b0dcc922 | ||
|
|
1bf7d2f3bd | ||
|
|
87127fd133 | ||
|
|
e85c15d178 | ||
|
|
0f56dce748 | ||
|
|
d2e2077ada | ||
|
|
9adbf47773 | ||
|
|
e5ee041d4e | ||
|
|
63a46a85f6 | ||
|
|
fe87d6d8be | ||
|
|
9387ecf043 | ||
|
|
35582cfe8a | ||
|
|
76e24653e9 | ||
|
|
4c2812b429 | ||
|
|
7154bc6857 | ||
|
|
c31070db24 | ||
|
|
336048441c | ||
|
|
cbac34347b | ||
|
|
3ee27a00c7 | ||
|
|
4ec020a86d | ||
|
|
464dabdc16 | ||
|
|
c0976ec099 | ||
|
|
7f3113b8d4 | ||
|
|
9180cbe821 | ||
|
|
c5daa754ff | ||
|
|
23a29216d3 | ||
|
|
8a2168ecf6 | ||
|
|
38d8a669b4 | ||
|
|
06e379a239 | ||
|
|
7c0379ce05 | ||
|
|
c7c13f2d5e | ||
|
|
6df9b3f38c | ||
|
|
ca81d94b90 | ||
|
|
a39ef7181d | ||
|
|
93b7e3431b | ||
|
|
dd02cc0747 | ||
|
|
867883453e | ||
|
|
a68784c319 | ||
|
|
46c763410f | ||
|
|
815d4572f6 | ||
|
|
279a191b86 | ||
|
|
f0da42917b | ||
|
|
6e87fd2d4c | ||
|
|
fbf5efb570 | ||
|
|
1a3323a261 | ||
|
|
b858fdd755 | ||
|
|
0aff827414 | ||
|
|
bd8a0a9f8f | ||
|
|
73d0e2cb81 | ||
|
|
47f816696c | ||
|
|
1cf455e91c | ||
|
|
952c8c2d64 | ||
|
|
dce3bf01fd | ||
|
|
7b1687d7e5 | ||
|
|
9ad6863567 | ||
|
|
4c1424bb83 | ||
|
|
c7364de2f0 | ||
|
|
e0043906be | ||
|
|
eda9fb5522 | ||
|
|
8a775144bf | ||
|
|
9b65534561 | ||
|
|
f6c0618596 | ||
|
|
15fd030fa4 | ||
|
|
693be03dcc | ||
|
|
6e3cb34024 | ||
|
|
bd7cd33b02 | ||
|
|
a72fdf7c26 | ||
|
|
850cbfe369 | ||
|
|
351db0632d | ||
|
|
d642e90cdd | ||
|
|
c454f7ac0d | ||
|
|
b5b47d7273 | ||
|
|
7c2c541729 | ||
|
|
f10abc8ee0 | ||
|
|
8ea27968d7 | ||
|
|
956db9c182 | ||
|
|
3eb3f38adf | ||
|
|
35b66e5ad1 | ||
|
|
d83ea305b5 | ||
|
|
c1d8508748 | ||
|
|
dc8f2bda2a | ||
|
|
f0f5acfa42 | ||
|
|
4e00edf8a7 | ||
|
|
02d5c00873 | ||
|
|
17009d28cf | ||
|
|
325a6a4e02 | ||
|
|
b51b24955c | ||
|
|
a954aaa507 | ||
|
|
ad475239a5 | ||
|
|
5e280674f9 | ||
|
|
6cdfd143b0 | ||
|
|
da454fa376 | ||
|
|
358dd4f791 | ||
|
|
2401abe17e | ||
|
|
56ea6b6e43 | ||
|
|
04691ed598 | ||
|
|
7366b55b14 | ||
|
|
a248bea50f | ||
|
|
c8c84bc419 | ||
|
|
5f990fb3a2 | ||
|
|
538c1eb660 | ||
|
|
9f704d7aa7 | ||
|
|
a5777300d8 | ||
|
|
57e1362344 | ||
|
|
c1ccbd58f5 | ||
|
|
09a2ab420b | ||
|
|
596770942a | ||
|
|
fe5e58af91 | ||
|
|
0a4c2f91f5 | ||
|
|
5b33a7dcbe | ||
|
|
c7e2b1230c | ||
|
|
bdf6a23de9 | ||
|
|
1a539b9830 | ||
|
|
3addd3420b | ||
|
|
6ea10dd153 | ||
|
|
bf0bee58b3 | ||
|
|
fbcbc60e85 | ||
|
|
0a9f06d60f | ||
|
|
f6956320f9 | ||
|
|
20bc323963 | ||
|
|
bcead5f0f4 | ||
|
|
cf3049ae34 | ||
|
|
ad9a9d8d35 | ||
|
|
14e9077584 | ||
|
|
43cf526b5f | ||
|
|
2d5c401d11 | ||
|
|
78cf68549f | ||
|
|
dececccd8e | ||
|
|
941ad27551 | ||
|
|
24e95ab38e | ||
|
|
c4de0b8255 | ||
|
|
7baaca4a76 | ||
|
|
ea248f6743 | ||
|
|
f03605d8ae | ||
|
|
0babf08926 | ||
|
|
6517b05abe | ||
|
|
fa91b5fd03 | ||
|
|
f831ccfc63 | ||
|
|
12084fc4f9 | ||
|
|
21237dae98 | ||
|
|
4bdc25d072 | ||
|
|
2f55abace2 | ||
|
|
3213e5df2d | ||
|
|
7e40147aa3 | ||
|
|
a2a26b26fb | ||
|
|
b3cf07d6cb | ||
|
|
ed76cd7574 | ||
|
|
01b8a71ee6 | ||
|
|
cc86bbf27d | ||
|
|
42cbb11de8 | ||
|
|
52303e8eda | ||
|
|
cf903be4a7 | ||
|
|
6306786645 | ||
|
|
d7b267843e | ||
|
|
3aefe375c1 | ||
|
|
3d6cc435ef | ||
|
|
973bd3a427 | ||
|
|
7d1ec51df5 | ||
|
|
9fb74399c8 | ||
|
|
bc0a6fffd1 | ||
|
|
fa85dd6527 | ||
|
|
73d595eecc | ||
|
|
3bf8b9ccf4 | ||
|
|
83262a67b1 | ||
|
|
66952a682d | ||
|
|
9df22c0093 | ||
|
|
27adfb76fa | ||
|
|
9c532eac07 | ||
|
|
2814815312 | ||
|
|
ab27586674 | ||
|
|
2749c5cac3 | ||
|
|
715cf311df | ||
|
|
312443235d | ||
|
|
0d95d63258 | ||
|
|
f86772f26c | ||
|
|
a7617e4d79 | ||
|
|
7612a83fa2 | ||
|
|
afbd18e8df | ||
|
|
be2bc61d38 | ||
|
|
dcee8beb99 | ||
|
|
fb8f72d5a9 | ||
|
|
b3f2416a09 | ||
|
|
b5ae2ccc3c | ||
|
|
05efc3eace | ||
|
|
24f8ff7548 | ||
|
|
c0c6782a17 | ||
|
|
d2ac672f47 | ||
|
|
e3d8d5f300 | ||
|
|
c5d5c9fcb5 | ||
|
|
2e040ee07a | ||
|
|
9846c46434 | ||
|
|
5c7c1af44e | ||
|
|
e119a82334 | ||
|
|
02db68aa67 | ||
|
|
10e1e7fd44 | ||
|
|
7aabe73521 | ||
|
|
37f85bb2d1 | ||
|
|
39fccc3699 | ||
|
|
53eccc1c1e | ||
|
|
c56292a6ec | ||
|
|
857cd6a28a | ||
|
|
303954ae8c | ||
|
|
3c338d1858 | ||
|
|
20d7882033 | ||
|
|
6927b0fb8d | ||
|
|
6e83f95c83 | ||
|
|
8f0c8a6561 | ||
|
|
a61b7056d5 | ||
|
|
f41ade9417 | ||
|
|
b0396e196f | ||
|
|
cf42fabfd8 | ||
|
|
52263bd5a3 | ||
|
|
24151a2028 | ||
|
|
c11e2d9e5e | ||
|
|
a8c9b2810b | ||
|
|
7a849ab7d1 | ||
|
|
c14d738d37 | ||
|
|
65478a6ff3 | ||
|
|
41be9232fe | ||
|
|
653932e50d | ||
|
|
09ef991e1a | ||
|
|
0f7029583c | ||
|
|
10eced9971 | ||
|
|
1d8b47785c | ||
|
|
ced271bec1 | ||
|
|
5d19afd422 | ||
|
|
b7363f7c18 | ||
|
|
aa2700ffa7 | ||
|
|
510e2a1d17 | ||
|
|
ebfe55f909 | ||
|
|
26fa9dea97 | ||
|
|
3bb4c0c237 | ||
|
|
255a875a2a | ||
|
|
2b5f3f1361 | ||
|
|
eb158545fc | ||
|
|
cade7b1132 | ||
|
|
d529736597 | ||
|
|
8dfc031c4d | ||
|
|
91c9859000 | ||
|
|
3a485a14a4 | ||
|
|
a61c27c4d0 | ||
|
|
e5cae2a2e4 | ||
|
|
7f961237f9 | ||
|
|
69a6538567 | ||
|
|
5b3c18ab84 | ||
|
|
907371453d | ||
|
|
81abffd145 | ||
|
|
44ef8fe5c8 | ||
|
|
cae78b3f91 | ||
|
|
c0fb814658 | ||
|
|
7ce0140c81 | ||
|
|
12b3034921 | ||
|
|
ec482ac867 | ||
|
|
ae52fb7a01 | ||
|
|
e8ff08e121 | ||
|
|
cc8e104cd6 | ||
|
|
5919a277bb | ||
|
|
96911d7790 | ||
|
|
acd3f7dba7 | ||
|
|
8aff3979db | ||
|
|
eafcd862be | ||
|
|
8826170635 | ||
|
|
c54e4d0900 | ||
|
|
52ca5c4aa2 | ||
|
|
95f8f80e74 | ||
|
|
7e380bb6f8 | ||
|
|
2477ffd860 | ||
|
|
a3dc46bf9d | ||
|
|
5c8e1b6eef | ||
|
|
ae9a8ce34c | ||
|
|
67b9a675f5 | ||
|
|
fae11e5a55 | ||
|
|
4daf75a469 | ||
|
|
d0293649cd | ||
|
|
353366ac54 | ||
|
|
1a8ffebb00 | ||
|
|
5ffbddcc57 | ||
|
|
5fbcbe7e52 | ||
|
|
7daa93cf5a | ||
|
|
9e32f29d19 | ||
|
|
1f25e38c2d | ||
|
|
c10a386d17 | ||
|
|
a13db82d28 | ||
|
|
ec392dc870 | ||
|
|
90d00fb095 | ||
|
|
e336b7f27e | ||
|
|
7f4c992dd7 | ||
|
|
ba1626a5b9 | ||
|
|
ab73c40bfe | ||
|
|
4016bc2416 | ||
|
|
9302daadc1 | ||
|
|
de7429e148 | ||
|
|
5892bd45d8 | ||
|
|
9317eccfc8 | ||
|
|
1236c4dafb | ||
|
|
f50f18f65a | ||
|
|
747cc4daa5 | ||
|
|
51b6a785e6 | ||
|
|
f4d41ef254 | ||
|
|
b9d80aa535 | ||
|
|
2f8213ca9a | ||
|
|
541b8cbb6c | ||
|
|
ed2e738ea4 | ||
|
|
17d9ba256b | ||
|
|
15dbac8193 | ||
|
|
2119854246 | ||
|
|
034c93fd65 | ||
|
|
ce91aba4de | ||
|
|
e33c09f8d4 | ||
|
|
a678c3f53e | ||
|
|
3e4fc7ff7f | ||
|
|
8dda07a1e9 | ||
|
|
e9f1851c5d | ||
|
|
ac659ff5a7 | ||
|
|
557f8e5a04 | ||
|
|
54de5ad3fa | ||
|
|
0709586e3a | ||
|
|
82ced33747 | ||
|
|
d31c5d7a2c | ||
|
|
2045487d5e | ||
|
|
4611e799b7 | ||
|
|
ffe9a2435b | ||
|
|
f5d8876384 | ||
|
|
d28265cfbe | ||
|
|
8059e83c49 | ||
|
|
d6f07c9f91 | ||
|
|
917cb8fa67 | ||
|
|
461db9e469 | ||
|
|
112908886c | ||
|
|
f734801da1 | ||
|
|
ea6dc7c710 | ||
|
|
cd81348ca5 | ||
|
|
ad91a09b07 | ||
|
|
040f73a3f4 | ||
|
|
0d8e0ddc4f | ||
|
|
8f9d7405ed | ||
|
|
72267e97ca | ||
|
|
19f87f0a89 | ||
|
|
9f7b1f0942 | ||
|
|
1ef888ca23 | ||
|
|
8b815bce94 | ||
|
|
97539db36d | ||
|
|
655fa5b8e0 | ||
|
|
9fbd3cc16f | ||
|
|
2295cbb815 | ||
|
|
198f8ea700 | ||
|
|
9fa9199747 | ||
|
|
1cd167a59a | ||
|
|
2868dc975c | ||
|
|
214ab16eb2 | ||
|
|
1c88d9575e | ||
|
|
1e4e02ddd3 | ||
|
|
f6fcddbe0b | ||
|
|
474180c112 | ||
|
|
c860573f13 | ||
|
|
c9c7354009 | ||
|
|
42eb7640f9 | ||
|
|
aafcd569b1 | ||
|
|
b549307ccf | ||
|
|
57090d4f8d | ||
|
|
764f7586de | ||
|
|
d96f2abc4e | ||
|
|
92f467e81c | ||
|
|
2442186a31 | ||
|
|
9fb74cb58a | ||
|
|
81e11c1d91 | ||
|
|
dc93350e0a | ||
|
|
3c6432da1f | ||
|
|
4eecb6841a | ||
|
|
3b83d3ff3a | ||
|
|
88b92a9605 | ||
|
|
3bb5baa6d2 | ||
|
|
59443d7ec6 | ||
|
|
c1d170e13d | ||
|
|
cffac6e11a | ||
|
|
79870472e1 | ||
|
|
1b69c94f76 | ||
|
|
cf8d1cf0e7 | ||
|
|
009fbeb543 | ||
|
|
9ceb8731d3 | ||
|
|
8f934bf817 | ||
|
|
88be2701f4 | ||
|
|
8ee62f0ac8 | ||
|
|
4d4308af78 | ||
|
|
f7c5eff35e | ||
|
|
3bc1644f34 | ||
|
|
27025b71db | ||
|
|
523d9ec3c2 | ||
|
|
aeb5455555 | ||
|
|
337390b590 | ||
|
|
836d950e05 | ||
|
|
ad096f77fc | ||
|
|
3774494f7e | ||
|
|
14fae5af9e | ||
|
|
65b48561a9 | ||
|
|
842dc14c18 | ||
|
|
af1afa7ba6 | ||
|
|
8c4c5e524b | ||
|
|
204bd7d2c4 | ||
|
|
f44014ff00 | ||
|
|
01719b02e2 | ||
|
|
4ba86bbe00 | ||
|
|
b85503b3b2 | ||
|
|
131a9aa1ac | ||
|
|
bd223606b1 | ||
|
|
f4fb80e523 | ||
|
|
49e466dd40 | ||
|
|
deec315f6a | ||
|
|
7fafe54e16 | ||
|
|
bdcbc829a0 | ||
|
|
4a64e86ecb | ||
|
|
1e2946ebc6 | ||
|
|
1ed5ca3fde | ||
|
|
aa62ac4042 | ||
|
|
e8f24910bd | ||
|
|
8d34e54dc5 | ||
|
|
c5ede3f167 | ||
|
|
1cd108e891 | ||
|
|
8878fd3028 | ||
|
|
a22d4e7962 | ||
|
|
25d2d7389f | ||
|
|
816b784399 | ||
|
|
c250f092bb | ||
|
|
b9c2bdf641 | ||
|
|
5ba90db049 | ||
|
|
88d20c5419 | ||
|
|
e158bee95f | ||
|
|
0139a77e94 | ||
|
|
e76d1b899b | ||
|
|
3fcdd6c9d7 | ||
|
|
bc916dbf35 | ||
|
|
96da2efb13 | ||
|
|
267cdf20e1 | ||
|
|
20c7df35c4 | ||
|
|
0f06e9926b | ||
|
|
93af424ce5 | ||
|
|
5e07400cd1 | ||
|
|
364a6a9444 | ||
|
|
b6bfd8e34f | ||
|
|
b05981ef27 | ||
|
|
42f1a56832 | ||
|
|
f667d56701 | ||
|
|
df5284beaf | ||
|
|
6d551b0d6e | ||
|
|
25e6339e2e | ||
|
|
f70fd30cd3 | ||
|
|
863d26558a | ||
|
|
cba12a1abd | ||
|
|
96d57a18ee | ||
|
|
e54ed10bc1 | ||
|
|
c8c807adcc | ||
|
|
cd6ed79433 | ||
|
|
ea4b3b74bb | ||
|
|
facfd64787 | ||
|
|
760a83d256 | ||
|
|
bbff19698b | ||
|
|
6f38cb162c | ||
|
|
af82224f82 | ||
|
|
a938e9473b | ||
|
|
3e88553d52 | ||
|
|
56245d5646 | ||
|
|
4af08b1606 | ||
|
|
fc4a395c88 | ||
|
|
de1813ab32 | ||
|
|
89ace66972 | ||
|
|
63f1857bda | ||
|
|
279500cba4 | ||
|
|
183270b443 | ||
|
|
a5f4332f21 | ||
|
|
6fad79f581 | ||
|
|
dff6274a93 | ||
|
|
082c872469 | ||
|
|
67a3dda53a | ||
|
|
950432eac0 | ||
|
|
6550e7d562 | ||
|
|
ffe75f3e20 | ||
|
|
8431874b15 | ||
|
|
54d2ccda99 | ||
|
|
926b6d9464 | ||
|
|
abfb6832c3 | ||
|
|
ceeea359fc | ||
|
|
ef35868bef | ||
|
|
cf48d297dd | ||
|
|
2b20e3d2b0 | ||
|
|
918cbdcf03 | ||
|
|
f5837dff9c | ||
|
|
ce04308c17 | ||
|
|
c0c20ebf3e | ||
|
|
823195a122 | ||
|
|
581583abb4 | ||
|
|
882fd48408 | ||
|
|
91238df13f | ||
|
|
ca806897c2 | ||
|
|
9118884e92 | ||
|
|
e403f8b620 | ||
|
|
6205b955da | ||
|
|
d265a04b19 | ||
|
|
afc09744b4 | ||
|
|
1e1d76d600 | ||
|
|
0b70aa0c56 | ||
|
|
4ca6591045 | ||
|
|
9717f2d374 | ||
|
|
469c8a1a4b | ||
|
|
9d47b15575 | ||
|
|
a11a204b8e | ||
|
|
e3c3d108fe | ||
|
|
8cadb5cf18 | ||
|
|
f10c8f2b4c | ||
|
|
5d2d701e1e | ||
|
|
f24d8473b1 | ||
|
|
3412ff7003 | ||
|
|
15e468f5dd | ||
|
|
a0dd504991 | ||
|
|
19b847b23b | ||
|
|
3b134c8fef | ||
|
|
c872f37aae | ||
|
|
3ce5b9b0d9 | ||
|
|
2d7c5f8c53 | ||
|
|
79c0fd27a0 | ||
|
|
b06d1ed072 | ||
|
|
52e7a4456a | ||
|
|
f1202ff152 | ||
|
|
e4db7cbd2b | ||
|
|
ff63204d17 | ||
|
|
4f3a3e93a9 | ||
|
|
b56d4b90ce | ||
|
|
6c2f9b3150 | ||
|
|
a808cdce13 | ||
|
|
a8629e1855 | ||
|
|
0146784e18 | ||
|
|
249b85af1e | ||
|
|
efc12ab28d | ||
|
|
5b2e7d4464 | ||
|
|
bcd3c13e2c | ||
|
|
7932e966db | ||
|
|
30d84643db | ||
|
|
264c91e620 | ||
|
|
db89be4106 | ||
|
|
85816a5ee2 | ||
|
|
5449e44381 | ||
|
|
20630b8744 | ||
|
|
3b63d1cb77 | ||
|
|
5703b9e737 | ||
|
|
02787b5674 | ||
|
|
4021da524c | ||
|
|
5adec0eae0 | ||
|
|
3f44f0b753 | ||
|
|
2a975f751b | ||
|
|
03bd049291 | ||
|
|
6ddd36666e | ||
|
|
3791db006e | ||
|
|
6bf8c0c17a | ||
|
|
80e1934f4e | ||
|
|
7415fdb79b | ||
|
|
b850b0dacf | ||
|
|
04e3d0c2fe | ||
|
|
3810519671 | ||
|
|
a08c8ef1fa | ||
|
|
6496a288b8 | ||
|
|
9f72eb3374 | ||
|
|
e71c71c6c2 | ||
|
|
0197fb35fe | ||
|
|
bcc5891e03 | ||
|
|
f90ab3c4c2 | ||
|
|
79280f3d93 | ||
|
|
ce79d0b9a4 | ||
|
|
a5b4a01594 | ||
|
|
5b25eeb449 | ||
|
|
fb259e8a50 | ||
|
|
b82dfe08a2 | ||
|
|
4671c9e672 | ||
|
|
00cdcd4d28 | ||
|
|
4e1fe88195 | ||
|
|
28ad475ab4 | ||
|
|
104e265633 | ||
|
|
382d237a60 | ||
|
|
de2fd659ab | ||
|
|
d2fda411f3 | ||
|
|
e02944c323 | ||
|
|
a01f4998c5 | ||
|
|
aa198594fd | ||
|
|
406a94bf76 | ||
|
|
fef1841fee | ||
|
|
1cb85fdea8 | ||
|
|
78263e81f1 | ||
|
|
053c8d5731 | ||
|
|
d69064f364 | ||
|
|
fedb24caf1 | ||
|
|
6ff8371254 | ||
|
|
7b6eaa819e | ||
|
|
e94aa296e2 | ||
|
|
98891103d0 | ||
|
|
383097a03a | ||
|
|
2b2f13ca79 | ||
|
|
78159a9435 | ||
|
|
b4af7b919e | ||
|
|
65056915d3 | ||
|
|
bc3f744e45 | ||
|
|
fb8da15b01 | ||
|
|
62f624b66b | ||
|
|
ef20053e72 | ||
|
|
aae68e4f82 | ||
|
|
1d715d7b1b | ||
|
|
1d7110ea8f | ||
|
|
80f70a58e3 | ||
|
|
f7aabeba04 | ||
|
|
02f6cac9d6 | ||
|
|
df54fc6098 | ||
|
|
fe0fb8d296 | ||
|
|
591120a7f7 | ||
|
|
878f074494 | ||
|
|
c1050da852 | ||
|
|
873daf079c | ||
|
|
df9e4bdd63 | ||
|
|
43ba1671f1 | ||
|
|
ce4b68d5fb | ||
|
|
8c18dd40a3 | ||
|
|
e3015bbfb7 | ||
|
|
817abd8b5f | ||
|
|
dbc9b00de5 | ||
|
|
b635e83651 | ||
|
|
7aeacdcc6c | ||
|
|
16e4a0c4bd | ||
|
|
d613800516 | ||
|
|
94b89216f7 | ||
|
|
153e09120a | ||
|
|
238c0c1b86 | ||
|
|
98ff213708 | ||
|
|
8a2a07eddb | ||
|
|
9076d543f3 | ||
|
|
cd77dc9563 | ||
|
|
9ccf80848d | ||
|
|
78cb565dc2 | ||
|
|
6a30452b4a | ||
|
|
e53442d983 | ||
|
|
bc079b29c3 | ||
|
|
cd6addd742 | ||
|
|
12d6e1cddd | ||
|
|
28e5ebd72b | ||
|
|
e8106109e3 | ||
|
|
c71d5a8a77 | ||
|
|
d1d27a0bd6 | ||
|
|
ebb7428479 | ||
|
|
3163a42f36 | ||
|
|
35a25c3dc2 | ||
|
|
f34f374179 | ||
|
|
aa330350fc | ||
|
|
a2cf1f98d9 | ||
|
|
f84def1b60 | ||
|
|
91d4c24078 | ||
|
|
8fe0b72a04 | ||
|
|
2bcdf741f9 | ||
|
|
9ae73e87eb | ||
|
|
77582ff5d4 | ||
|
|
52a2dfe08b | ||
|
|
09d2165d36 | ||
|
|
fb9c1f7e65 | ||
|
|
abf05af474 | ||
|
|
714ba2a58d | ||
|
|
405ff0377a | ||
|
|
8421ef7b4a | ||
|
|
fd151c4fc6 | ||
|
|
b36b20d246 | ||
|
|
44ffe41775 | ||
|
|
2ca7c2629c | ||
|
|
483c0e4cea | ||
|
|
7d51bf0eb0 | ||
|
|
ab4457e2a3 | ||
|
|
1eb6d617f5 | ||
|
|
21ac34bc6a | ||
|
|
c050a82c3a | ||
|
|
750408d0a2 | ||
|
|
a44a313f77 | ||
|
|
d159602928 | ||
|
|
50e817f193 | ||
|
|
929a10e33d | ||
|
|
c38aeb1081 | ||
|
|
35e0894655 | ||
|
|
943f0d475f | ||
|
|
96cbab2b22 | ||
|
|
36c85a617a | ||
|
|
1356498ee1 | ||
|
|
49ec53f4ae | ||
|
|
5687a03f0b | ||
|
|
cdb2a0736a | ||
|
|
cfd3efb6e7 | ||
|
|
8ec0d813c0 | ||
|
|
ea5333e5f7 | ||
|
|
b13723d3d7 | ||
|
|
03a4e0c837 | ||
|
|
f49c20c508 | ||
|
|
d3821123ee | ||
|
|
759ab8acbc | ||
|
|
7a88071a16 | ||
|
|
f3c4d1a181 | ||
|
|
4e491757ef | ||
|
|
5936ed7941 | ||
|
|
6b56f7d643 | ||
|
|
e618a21f4e | ||
|
|
0f271ab535 | ||
|
|
4c054917ef | ||
|
|
b9eabe532e | ||
|
|
4ee292a952 | ||
|
|
adc2900aff | ||
|
|
9c801e9c08 | ||
|
|
ba0791b896 | ||
|
|
c4a67b7d02 | ||
|
|
bd572c775d | ||
|
|
65329496a7 | ||
|
|
2288ec7384 | ||
|
|
80b3b9e00c | ||
|
|
3876c1679a | ||
|
|
ba85f4a62a | ||
|
|
a1b34ef0ef | ||
|
|
f03d2d1b33 | ||
|
|
c7048973bb | ||
|
|
e800e84a77 | ||
|
|
d306fcb8a2 | ||
|
|
44339a6447 | ||
|
|
675aadc6a9 | ||
|
|
d95c09d94a | ||
|
|
f508fd3fa2 | ||
|
|
cf96ad8ef9 | ||
|
|
066a2828c4 | ||
|
|
b6c11154ae | ||
|
|
6ca897e055 | ||
|
|
23ffa1905a | ||
|
|
a88e5968ae | ||
|
|
4abaf62783 | ||
|
|
9bf5b92d8f | ||
|
|
044f525eb8 | ||
|
|
554d9bc6ce | ||
|
|
49654803aa | ||
|
|
44c951e432 | ||
|
|
e1b8c30163 | ||
|
|
70faa4ff36 | ||
|
|
63b63cd66d | ||
|
|
137980b46e | ||
|
|
055d839fc3 | ||
|
|
3e39dd49aa | ||
|
|
082b4fb193 | ||
|
|
de1f119a7d | ||
|
|
7ce12863b8 | ||
|
|
1ab69948a5 | ||
|
|
13298d84ea | ||
|
|
c2c5b28c70 | ||
|
|
6e200ed1c0 | ||
|
|
3fadbb29a1 | ||
|
|
6e4eef4a49 | ||
|
|
8feb09aa89 | ||
|
|
e1a3bab7e5 | ||
|
|
e0cd5650c5 | ||
|
|
80c09f0845 | ||
|
|
1f831c6037 | ||
|
|
cc0075e988 | ||
|
|
4b44a75bc1 | ||
|
|
f46beec20d | ||
|
|
973bf67683 | ||
|
|
ff6a918e7e | ||
|
|
5ef2666127 | ||
|
|
ed001a5f55 | ||
|
|
13ebbd1a2b | ||
|
|
ca8e556619 | ||
|
|
8900c84155 | ||
|
|
002d927874 | ||
|
|
cef5bf2768 | ||
|
|
529543b36d | ||
|
|
636e4d38d5 | ||
|
|
2d8e11b78b | ||
|
|
0e2993a6c8 | ||
|
|
f0ebad3f21 | ||
|
|
a02adcc2ef | ||
|
|
d1850aaada | ||
|
|
cf21a15e06 | ||
|
|
13124542cf | ||
|
|
cd5809d11f | ||
|
|
28938ddb32 | ||
|
|
3c551fd36f | ||
|
|
94c495c8ed | ||
|
|
f54c801bd2 | ||
|
|
429972b5c5 | ||
|
|
9b8a4d0c76 | ||
|
|
235f3ce0ba | ||
|
|
06806a1ea1 | ||
|
|
b1a85d89d2 | ||
|
|
6fc30962d6 | ||
|
|
849446ae17 | ||
|
|
479720c169 | ||
|
|
0e94c6b025 | ||
|
|
1a51257b71 | ||
|
|
4e74ba996d | ||
|
|
80a87e5f9e | ||
|
|
a526d3c1f2 | ||
|
|
d67bec0740 | ||
|
|
b2e11c504b | ||
|
|
1b38ee8b46 | ||
|
|
afa4a234f9 | ||
|
|
46b9006de2 | ||
|
|
d54ecc3961 | ||
|
|
fa54950d2e | ||
|
|
0ac7a93c28 | ||
|
|
bc2a66da32 | ||
|
|
bcced90f11 | ||
|
|
eb076165d2 | ||
|
|
9248919b05 | ||
|
|
5472589ddd | ||
|
|
19f5183176 | ||
|
|
beb6e25ef0 | ||
|
|
0ad49c25aa | ||
|
|
d46823333d | ||
|
|
836f645621 | ||
|
|
96be450cbb | ||
|
|
56cb415509 | ||
|
|
2ef2136c2c | ||
|
|
0b16b4481a | ||
|
|
0b18f1b948 | ||
|
|
a4d4a30a6b | ||
|
|
98bbc73925 | ||
|
|
bb7f4abd4b | ||
|
|
bd63b5a231 | ||
|
|
590f3d0e8f | ||
|
|
6cbfa01176 | ||
|
|
f929e1b105 | ||
|
|
77104395ce | ||
|
|
c0d5853c63 | ||
|
|
5bbf5105f1 | ||
|
|
5b193d014e | ||
|
|
fc7a63a4de | ||
|
|
aec1869d32 | ||
|
|
377169959d | ||
|
|
ba497ce57d | ||
|
|
5e7d12fefa | ||
|
|
a019d3cd83 | ||
|
|
8c6a592523 | ||
|
|
47a1774dc0 | ||
|
|
2bc0c57f18 | ||
|
|
f0705a928a | ||
|
|
22f9322905 | ||
|
|
6795e78edf | ||
|
|
31620fea3a | ||
|
|
6b6f2b5414 | ||
|
|
c498348a34 | ||
|
|
00fc731d64 | ||
|
|
d80d112e09 | ||
|
|
65d723d53c | ||
|
|
fb3fae43c0 | ||
|
|
41108f497b | ||
|
|
beefda7f60 | ||
|
|
74cdc1cf3e | ||
|
|
7f3be083c1 | ||
|
|
b8012a2281 | ||
|
|
95ea67de28 | ||
|
|
1fbd84da39 | ||
|
|
beb5b1ad58 | ||
|
|
77a67484ea | ||
|
|
0b4e70e38b | ||
|
|
8f0b5d2d97 | ||
|
|
0e3e4f269d | ||
|
|
d6c5ee86c5 | ||
|
|
3772a29557 | ||
|
|
7831e0040e | ||
|
|
3780f3152c | ||
|
|
3146f8bdbc | ||
|
|
256080e2a2 | ||
|
|
47510e2912 | ||
|
|
0c06276b48 | ||
|
|
d66d5cc17e | ||
|
|
df0c51a63b | ||
|
|
c34da133f6 | ||
|
|
f237222bc9 | ||
|
|
2a4ccaf993 | ||
|
|
ac50a14b6a | ||
|
|
06f71d883c | ||
|
|
9ace6af3df | ||
|
|
9062f60e3d | ||
|
|
2307756892 | ||
|
|
7008493f03 | ||
|
|
b5a89e8907 | ||
|
|
ae58838cc5 | ||
|
|
9a4fc3e086 | ||
|
|
0241f1a29c | ||
|
|
801e44f4eb | ||
|
|
856ce06fda | ||
|
|
d406d3a058 | ||
|
|
0b8e8144af | ||
|
|
ad26026802 | ||
|
|
c2b8f9a7c3 | ||
|
|
ba79977f07 | ||
|
|
16e2193911 | ||
|
|
bb5d26ba9e | ||
|
|
59f9073e21 | ||
|
|
982f85bf90 | ||
|
|
acdf70e928 | ||
|
|
d182f7e4b2 | ||
|
|
790079c3b6 | ||
|
|
dda6d7f9e1 | ||
|
|
256f0fc765 | ||
|
|
e1f320276e | ||
|
|
c61bd6c84d | ||
|
|
8a343aedf2 | ||
|
|
cd729e83b6 | ||
|
|
cfb36525ab | ||
|
|
6f58a9d643 | ||
|
|
0913329b03 | ||
|
|
402b04a68c | ||
|
|
4a68b4add4 | ||
|
|
a74c4db948 | ||
|
|
0fc5ccb76c | ||
|
|
98a745b3df | ||
|
|
24009ed00f | ||
|
|
fceab511b3 | ||
|
|
c6421136f9 | ||
|
|
2f8b75d86e | ||
|
|
97ec5d52c3 | ||
|
|
89fcb40557 | ||
|
|
5c705ab675 | ||
|
|
2f21b94a76 | ||
|
|
6f1ae147da | ||
|
|
f2d503ad04 | ||
|
|
57ee34839d | ||
|
|
82d8526732 | ||
|
|
742027a447 | ||
|
|
efed2ae30f | ||
|
|
54830e8401 | ||
|
|
ce1a8d70d9 | ||
|
|
cd719a8c85 | ||
|
|
3df53836ca | ||
|
|
7bb058215d | ||
|
|
272015c701 | ||
|
|
21a27e3b65 | ||
|
|
22516437b7 | ||
|
|
ea53f1bec7 | ||
|
|
33bf5cf42a | ||
|
|
c976799f8c | ||
|
|
f973b9e0e5 | ||
|
|
60321352aa | ||
|
|
6d60224c93 | ||
|
|
2b2434d239 | ||
|
|
f8bea661fc | ||
|
|
86225d0eb6 | ||
|
|
3351c972e7 | ||
|
|
460e170f7a | ||
|
|
1a2d39bdf9 | ||
|
|
99325040f8 | ||
|
|
568fcbda54 | ||
|
|
f4b186a9d3 | ||
|
|
d862ae17eb | ||
|
|
9f73131621 | ||
|
|
99310a5bbb | ||
|
|
1673bf2d44 | ||
|
|
4c656ea22f | ||
|
|
7707e3d887 | ||
|
|
ba204d0330 | ||
|
|
cbb327227a | ||
|
|
14fa2f47f5 | ||
|
|
579da8cc9b | ||
|
|
5693d7d733 | ||
|
|
07c8fdffd1 | ||
|
|
d3f4db649f | ||
|
|
abbe237cc0 | ||
|
|
ac4a65ddfd | ||
|
|
693215723a | ||
|
|
5f0e474be1 | ||
|
|
0e201c4c18 | ||
|
|
d12ca22b19 | ||
|
|
c7b80c28a1 | ||
|
|
5c2288218f | ||
|
|
0844fa38a8 | ||
|
|
3ed33c5856 | ||
|
|
b3e466ccb6 | ||
|
|
875cf9a054 | ||
|
|
ca85d217ec | ||
|
|
6652b1f4f3 | ||
|
|
9fe04f5659 | ||
|
|
5b9e51bfaa | ||
|
|
cdea744725 | ||
|
|
44365f2e27 | ||
|
|
888dbd7d11 | ||
|
|
76ddfc4a9e | ||
|
|
7950a646c3 | ||
|
|
09819f8b2e | ||
|
|
69daa24869 | ||
|
|
35214b6dec | ||
|
|
fe6bf6966b | ||
|
|
e0276ed4b4 | ||
|
|
fce487669b | ||
|
|
e6ba373d08 | ||
|
|
d4b3d504e4 | ||
|
|
2b2376d4c0 | ||
|
|
51bdf01e2e | ||
|
|
9d29fbbf80 | ||
|
|
a40fc50e5e | ||
|
|
df4e4534f4 | ||
|
|
fca6e466b1 | ||
|
|
0321174519 | ||
|
|
c452f8c430 | ||
|
|
079c1d8786 | ||
|
|
0677567cdd | ||
|
|
7fe7c30b17 | ||
|
|
cc1d8060c4 | ||
|
|
428a82e734 | ||
|
|
cc235fc312 | ||
|
|
249f97d1ed | ||
|
|
3e9310d6cd | ||
|
|
9051c5891e | ||
|
|
56d94e6974 | ||
|
|
e6a96bea47 | ||
|
|
cf82e37c36 | ||
|
|
4fb3e0500a | ||
|
|
9c7d51429e | ||
|
|
c1985443fd | ||
|
|
17a27fd312 | ||
|
|
557ffdbe35 | ||
|
|
e9bfe34850 | ||
|
|
1a4540d386 | ||
|
|
a0c4b1e061 | ||
|
|
e275ba8d2e | ||
|
|
db7eeee07b | ||
|
|
84d5f24f5f | ||
|
|
42948b70e3 | ||
|
|
28d3bd03b2 | ||
|
|
6148f862b9 | ||
|
|
0a32610b37 | ||
|
|
514759bde7 | ||
|
|
2eb27ffb4a | ||
|
|
2ce24fdbf8 | ||
|
|
e9ae10e569 | ||
|
|
a1940418fb | ||
|
|
6fdc62c008 | ||
|
|
5e5cb7a292 | ||
|
|
f5ab3e41c5 | ||
|
|
036bdde764 | ||
|
|
691bf85d7e | ||
|
|
4482965d80 | ||
|
|
fdca8fb592 | ||
|
|
c7c32210e6 | ||
|
|
316a04f606 | ||
|
|
c4da2afb22 | ||
|
|
9eaa45a291 | ||
|
|
81a9439eb2 | ||
|
|
be9b550209 | ||
|
|
6653813cb9 | ||
|
|
cf1278295d | ||
|
|
cdb5ddb2da | ||
|
|
1cdebb68a0 | ||
|
|
fece42ce0a | ||
|
|
c5867b2876 | ||
|
|
43e257e7de | ||
|
|
9dcdeb15ec | ||
|
|
060a209ecb | ||
|
|
e1e3da946f | ||
|
|
49a9f74753 | ||
|
|
74b19843ae | ||
|
|
d691e28675 | ||
|
|
2a5f0d6063 | ||
|
|
66a0813e44 | ||
|
|
64d6d25d65 | ||
|
|
b443c20cef | ||
|
|
5e8c8367f3 | ||
|
|
2b0f846f1b | ||
|
|
e7713a28ae | ||
|
|
7948d071e0 | ||
|
|
fb23717102 | ||
|
|
3d959c46d0 | ||
|
|
4cdd61eb78 | ||
|
|
6d08d84011 | ||
|
|
f6cafd1a15 | ||
|
|
5792887883 | ||
|
|
e82ee731bf | ||
|
|
5e09aae4ca | ||
|
|
740f7b0fb6 | ||
|
|
7510a6f66a | ||
|
|
1ff7d458a5 | ||
|
|
c3528fb201 | ||
|
|
3f5dff35f8 | ||
|
|
08bfe2b263 | ||
|
|
42645a7e0a | ||
|
|
7d4c8ef6b2 | ||
|
|
a1d7b8db6f | ||
|
|
4a3a4558e2 | ||
|
|
1b83fc85cd | ||
|
|
c1a10b6056 | ||
|
|
841a9b4c8a | ||
|
|
f3db02018f | ||
|
|
4cbaee59cd | ||
|
|
0d10aa4098 | ||
|
|
f3f8aa5397 | ||
|
|
4970af6bb9 | ||
|
|
a48aebc78c | ||
|
|
26bbddde8f | ||
|
|
b48a556de5 | ||
|
|
aab5c490dc | ||
|
|
d54cc49d66 | ||
|
|
0cef22ef83 | ||
|
|
7b2f712e20 | ||
|
|
1a92127dfa | ||
|
|
26a05292b9 | ||
|
|
caaa79bb76 | ||
|
|
b80c0d85e0 | ||
|
|
0641281cfe | ||
|
|
f414853d70 | ||
|
|
7c677c5057 | ||
|
|
969c7d1c8e | ||
|
|
b202480a66 | ||
|
|
9e80764c2b | ||
|
|
f5a5320f8f | ||
|
|
7389fc0e25 | ||
|
|
ce915d3438 | ||
|
|
3ef910d23e | ||
|
|
845b26a73b | ||
|
|
e0545e2f94 | ||
|
|
01341d983c | ||
|
|
0d68e10dd7 | ||
|
|
e6a60c0dc5 | ||
|
|
7dbd5acbb1 | ||
|
|
7a87f3cfb8 | ||
|
|
b817225fb8 | ||
|
|
a097c848bb | ||
|
|
a47d3e3e35 | ||
|
|
4d4bcaab1e | ||
|
|
265a3dff27 | ||
|
|
97fe3972c8 | ||
|
|
7c91ce2fa7 | ||
|
|
951993db17 | ||
|
|
357a1a982b | ||
|
|
f6f69b408f | ||
|
|
98399b85e3 | ||
|
|
38a773f245 | ||
|
|
e9e2e5026c | ||
|
|
8649de6199 | ||
|
|
3885a2a20f | ||
|
|
dde9fddae4 | ||
|
|
3a08e6df9d | ||
|
|
f427bec31c | ||
|
|
67e0739bec | ||
|
|
c7022cc139 | ||
|
|
65a0de8979 | ||
|
|
d0134722af | ||
|
|
efc7181aa0 | ||
|
|
3729d269d0 | ||
|
|
7dd8a7f2e3 | ||
|
|
56bbcfc3ee | ||
|
|
eec6212cdf | ||
|
|
a5b3b8743a | ||
|
|
4dd9072a2b | ||
|
|
073285409b | ||
|
|
41da61dd6a | ||
|
|
35e8dae939 | ||
|
|
05e77b69c4 | ||
|
|
745eefe0be | ||
|
|
d7165b4720 | ||
|
|
7b1163f75c | ||
|
|
507f5623f4 | ||
|
|
5ace7c9c66 | ||
|
|
3b35b762cb | ||
|
|
dbd3865e3b | ||
|
|
6bf1e6fa06 | ||
|
|
1c0170554e | ||
|
|
1d79254053 | ||
|
|
974ab5a8dd | ||
|
|
eaebf4b896 | ||
|
|
cf747e1b82 | ||
|
|
455fe15bd1 | ||
|
|
c4d0eb9350 | ||
|
|
10d95348b1 | ||
|
|
f86b1cf6a1 | ||
|
|
259e9cfccf | ||
|
|
7318b20f55 | ||
|
|
322a36f365 | ||
|
|
b8b20eac6d | ||
|
|
1fb123d701 | ||
|
|
138f4bd850 | ||
|
|
20abf31093 | ||
|
|
4abc551f9e | ||
|
|
67707763f7 | ||
|
|
df8915cf5c | ||
|
|
a1d16c61ec | ||
|
|
64b5eb8279 | ||
|
|
c66122c255 | ||
|
|
b792175ec5 | ||
|
|
b944bee121 | ||
|
|
88ff2f79d5 | ||
|
|
c3fa1fb736 | ||
|
|
e9eb9edc23 | ||
|
|
e8018d8008 | ||
|
|
694a10f604 | ||
|
|
b2378c01ea | ||
|
|
e2451484d9 | ||
|
|
dccdc950bf | ||
|
|
dd7be2bfd8 | ||
|
|
66b05163e3 | ||
|
|
7789bf6907 | ||
|
|
8b6abe0151 | ||
|
|
25eb40ab31 | ||
|
|
0336c1fa37 | ||
|
|
09541de076 | ||
|
|
2583fb66cc | ||
|
|
037ea92679 | ||
|
|
38f65e7053 | ||
|
|
ebbc416d4b | ||
|
|
13c4f8da2b | ||
|
|
099b8c9fa5 | ||
|
|
1638d32e1c | ||
|
|
13e1c93c74 | ||
|
|
affbd48a3f | ||
|
|
00f83ca7af | ||
|
|
441bd25f90 | ||
|
|
128df57005 | ||
|
|
a80cd26341 | ||
|
|
700212608a | ||
|
|
8fb064ed70 | ||
|
|
a92eb1f33d | ||
|
|
2454e67e09 | ||
|
|
862a490038 | ||
|
|
ffc57d5f20 | ||
|
|
e96654ced1 | ||
|
|
dd763b45e1 | ||
|
|
2710841801 | ||
|
|
a9e1eabcbd | ||
|
|
30a2e47390 | ||
|
|
f7076c38ea | ||
|
|
e6d522493b | ||
|
|
c286573f5c | ||
|
|
aef18b7359 | ||
|
|
17e183f5cf | ||
|
|
a53d8ed4e4 | ||
|
|
755e329b01 | ||
|
|
765c466d6d | ||
|
|
cf3becfb2e | ||
|
|
b508f642b2 | ||
|
|
3fcee21ff7 | ||
|
|
b01cb41950 | ||
|
|
7642cbb5b7 | ||
|
|
7a6334d920 | ||
|
|
d96bc38bea | ||
|
|
a31a569d52 | ||
|
|
0d3aacd316 | ||
|
|
ece8a3e701 | ||
|
|
ceb3980b93 | ||
|
|
01eba1b8d9 | ||
|
|
cf28ea0d1c | ||
|
|
41dd3b11b7 | ||
|
|
5a1687484c | ||
|
|
6143338116 | ||
|
|
39c232548c | ||
|
|
e2a93e17f9 | ||
|
|
0b990443de | ||
|
|
02fe19effa | ||
|
|
920cc9ac38 | ||
|
|
ba22890205 | ||
|
|
a59cfa7670 | ||
|
|
7cdd7c5333 | ||
|
|
e3379b960e | ||
|
|
7b675864a8 | ||
|
|
3b853b329f | ||
|
|
537c515dde | ||
|
|
238afbc2f8 | ||
|
|
2a71c20ee4 | ||
|
|
40c66b1741 | ||
|
|
94ad808028 | ||
|
|
0c8b5ed59a | ||
|
|
56fe23549c | ||
|
|
867d7e5d25 | ||
|
|
a0cd761c96 | ||
|
|
7c3502f031 | ||
|
|
61ab07ced3 | ||
|
|
281c6d6069 | ||
|
|
82634dfe3b | ||
|
|
9be3394bac | ||
|
|
4228ee326c | ||
|
|
fa1110e4d3 | ||
|
|
050c47d3a7 | ||
|
|
161895ed1a | ||
|
|
aeffdc3632 | ||
|
|
ecf0da1796 | ||
|
|
990fafa988 | ||
|
|
ceb0a8b3e3 | ||
|
|
3b283f3167 | ||
|
|
2b29d08064 | ||
|
|
86ed3de1c1 | ||
|
|
acf035d848 | ||
|
|
cab71c9711 | ||
|
|
c17440f5b4 | ||
|
|
fd566bda14 | ||
|
|
e47dccbe87 | ||
|
|
2a172f9779 | ||
|
|
ce630a6381 | ||
|
|
a882798143 | ||
|
|
44f9327087 | ||
|
|
e654676148 | ||
|
|
840e266b5d | ||
|
|
7d89fa2591 | ||
|
|
5f67c023a2 | ||
|
|
af3e5b299c | ||
|
|
b3b4013637 | ||
|
|
9ad341d668 | ||
|
|
d7a8d9a1c7 | ||
|
|
2d36ae6326 | ||
|
|
208ba02a4a | ||
|
|
4cdb21c5cd | ||
|
|
5d6cc8125b | ||
|
|
237933069e | ||
|
|
99660db73f | ||
|
|
68fa676cbf | ||
|
|
8794f002d5 | ||
|
|
d52ef185b1 | ||
|
|
3ca77c46c7 | ||
|
|
89d5d807ee | ||
|
|
9e3427a37e | ||
|
|
7ce25ecfca | ||
|
|
1ca77bee26 | ||
|
|
5dbc7cc68d | ||
|
|
0d45c78917 | ||
|
|
31fb4f7c8b | ||
|
|
e9acb6fad5 | ||
|
|
ab402e1178 | ||
|
|
293701f520 | ||
|
|
7b38ba0e65 | ||
|
|
5d8ee8fc28 | ||
|
|
3e2e4be680 | ||
|
|
3863fe6412 | ||
|
|
2b71ea21ad | ||
|
|
36f21c5a4f | ||
|
|
cf90bd9c86 | ||
|
|
5f159c43c5 | ||
|
|
c02613e15f | ||
|
|
32cd1175fb | ||
|
|
0152e053e1 | ||
|
|
8d1e73edc7 | ||
|
|
a5f51eadf1 | ||
|
|
4ac21a4f63 | ||
|
|
91fdf2aa25 | ||
|
|
44614d4a7d | ||
|
|
0e9f617667 | ||
|
|
7e7e348a14 | ||
|
|
cc3d0d1ef7 | ||
|
|
5b608718bb | ||
|
|
3a6ab81549 | ||
|
|
c48681b2f0 | ||
|
|
86d786cbc0 | ||
|
|
ec653b7b80 | ||
|
|
cbc34e1c8a | ||
|
|
1f37d94f9e | ||
|
|
3ee0e041fa | ||
|
|
4074f4fffa | ||
|
|
7286fd6e3f | ||
|
|
4b608117a2 | ||
|
|
47b4d245aa | ||
|
|
36ff508fec | ||
|
|
772b5fdf0f | ||
|
|
eace21dcae | ||
|
|
163080b609 | ||
|
|
4938fbffa8 | ||
|
|
d5db20c296 | ||
|
|
415cb857d9 | ||
|
|
a641250da6 | ||
|
|
4d674a3f17 | ||
|
|
12d9a13af0 | ||
|
|
164841f299 | ||
|
|
778361686c | ||
|
|
29907a4c3f | ||
|
|
81f38342bf | ||
|
|
36b93c8dc7 | ||
|
|
e95fdbbc37 | ||
|
|
3001f115b6 | ||
|
|
21649d81d2 | ||
|
|
5118ba3dd2 | ||
|
|
f9409cbe43 | ||
|
|
572d17f46b | ||
|
|
f466f1bf46 | ||
|
|
594315d90b | ||
|
|
f84895f1f1 | ||
|
|
952810f76c | ||
|
|
73ccbedcdb | ||
|
|
7ef83311bb | ||
|
|
416c376077 | ||
|
|
ef83a07066 | ||
|
|
ae0c1573fd | ||
|
|
378e5acd23 | ||
|
|
a56daa6c06 | ||
|
|
84399e62ae | ||
|
|
387615e99f | ||
|
|
f98ab2d037 | ||
|
|
19ce08b4d0 | ||
|
|
8cc2dc715c | ||
|
|
ca20a2dc06 | ||
|
|
f9b1a96c89 | ||
|
|
854f07d735 | ||
|
|
7f4f01009b | ||
|
|
117b01acbd | ||
|
|
2b38ddf78d | ||
|
|
5e51107711 | ||
|
|
3bb33bdeed | ||
|
|
9b9fa009d1 | ||
|
|
072ad8d371 | ||
|
|
8846ffec64 | ||
|
|
3b72ed6e1a | ||
|
|
35b7c0f558 | ||
|
|
d5d80f4247 | ||
|
|
e915ed182d | ||
|
|
c3aed2543e | ||
|
|
e502ad13f9 | ||
|
|
952d924581 | ||
|
|
0484aba892 | ||
|
|
03c84d0f11 | ||
|
|
086f98471e | ||
|
|
cc4f0d8acc | ||
|
|
c7bd4b5c1d | ||
|
|
14e3b34a8e | ||
|
|
6354dddff2 | ||
|
|
c50c3699d9 | ||
|
|
6a7f955818 | ||
|
|
9cf457be0a | ||
|
|
e31383a8f1 | ||
|
|
13b8dc61ba | ||
|
|
61085f6141 | ||
|
|
d8cb1daa78 | ||
|
|
de2e341947 | ||
|
|
e944a0239d | ||
|
|
ce8db12b22 | ||
|
|
1d41129b6c | ||
|
|
6d6c3ad2c4 | ||
|
|
0b532579d8 | ||
|
|
b9007dc721 | ||
|
|
211efffa10 | ||
|
|
e3b50b7d12 | ||
|
|
aae49f1d68 | ||
|
|
6b4141247e | ||
|
|
327f6e7e25 | ||
|
|
296c0a6b70 | ||
|
|
356b6e0483 | ||
|
|
08a473fb35 | ||
|
|
893eef846d | ||
|
|
4ecd35c275 | ||
|
|
27a7d9f9d1 | ||
|
|
c0abab226d | ||
|
|
f1320b79ce | ||
|
|
bf41197b97 | ||
|
|
3f7fcad9ac | ||
|
|
e2ad0ed9f7 | ||
|
|
d2158966db | ||
|
|
8086c66ab8 | ||
|
|
7d37195c1a | ||
|
|
241cf10bdb | ||
|
|
337ae05ed8 | ||
|
|
378e39d7ad | ||
|
|
8fb3aef917 | ||
|
|
c86cb4e9a5 | ||
|
|
8ca240fb2c | ||
|
|
9ea697ac09 | ||
|
|
37eaa49e4c | ||
|
|
e64ca7c583 | ||
|
|
62a7a07127 | ||
|
|
bc618ec290 | ||
|
|
0780859a4d | ||
|
|
79818f73c0 | ||
|
|
957d7fbe2a | ||
|
|
6e9d3092a7 | ||
|
|
7dab927260 | ||
|
|
c417517f43 | ||
|
|
fd0314a6bd | ||
|
|
6a05d60f41 | ||
|
|
cd84c5ad08 | ||
|
|
b0187d7f28 | ||
|
|
7a1d64fff9 | ||
|
|
debcf19199 | ||
|
|
ea76425f86 | ||
|
|
88936b6216 | ||
|
|
e6edcd9a7f | ||
|
|
af78762421 | ||
|
|
bf159bd316 | ||
|
|
9eda40234f | ||
|
|
00336f554f | ||
|
|
a524b9ae9b | ||
|
|
3f1bcac077 | ||
|
|
679ced7840 | ||
|
|
7422f54212 | ||
|
|
b0384d0335 | ||
|
|
6b64039fcb | ||
|
|
19e7c708ce | ||
|
|
c8ca5803fc | ||
|
|
5f48abb451 | ||
|
|
491fd6b74d | ||
|
|
f1ff24d634 | ||
|
|
0242383ec3 | ||
|
|
768887fc0f | ||
|
|
958c13e02d | ||
|
|
f417b51fb6 | ||
|
|
47a1f757a9 | ||
|
|
27ad3b917f | ||
|
|
93a5784c58 | ||
|
|
e9fd73141d | ||
|
|
6c005b3d35 | ||
|
|
2967bc5988 | ||
|
|
55772eec5a | ||
|
|
c2adda1cfe | ||
|
|
51d77aea2e | ||
|
|
8f456ea73b | ||
|
|
6459582952 | ||
|
|
3796882d22 | ||
|
|
4db69c8eac | ||
|
|
f6a86e5527 | ||
|
|
063b35f1dc | ||
|
|
b61147aed0 | ||
|
|
fd3516bc82 | ||
|
|
5e4a91f996 | ||
|
|
df4331da04 | ||
|
|
fe3a983d35 | ||
|
|
53c349cb86 | ||
|
|
8f37f15a33 | ||
|
|
81385cf820 | ||
|
|
cce65e19e1 | ||
|
|
c4b02645f5 | ||
|
|
49e70746f0 | ||
|
|
00ace3bb63 | ||
|
|
efde37eb36 | ||
|
|
84499ab969 | ||
|
|
5d26bb2566 | ||
|
|
657450c40c | ||
|
|
cf2b659491 | ||
|
|
e9679ce993 | ||
|
|
68c5d61d60 | ||
|
|
c4f0236ec0 | ||
|
|
1839c144fa | ||
|
|
d077936a21 | ||
|
|
6c1638890c | ||
|
|
7f0f789953 | ||
|
|
83a2a7a1c2 | ||
|
|
70fb4d452e | ||
|
|
5ed1d4e178 | ||
|
|
35834d3dba | ||
|
|
260d9b9770 | ||
|
|
3907e9eedd | ||
|
|
cf8b00890f | ||
|
|
f1fd25e95e | ||
|
|
426503e062 | ||
|
|
b1834b7cf8 | ||
|
|
27f9cd591d | ||
|
|
5bbc7c8ba2 | ||
|
|
08f8f58971 | ||
|
|
7871e705bf | ||
|
|
1820308ba2 | ||
|
|
a07229846f | ||
|
|
a7e4656834 | ||
|
|
872d54a2dd | ||
|
|
496136b52c | ||
|
|
c4eff00ed7 | ||
|
|
27d8aa0f04 | ||
|
|
bb057b1dad | ||
|
|
3b9d84e2b1 | ||
|
|
1a17de9d39 | ||
|
|
f6ade5dc84 | ||
|
|
2116f19106 | ||
|
|
b73a7e07d2 | ||
|
|
14d3a624d8 | ||
|
|
dd88345483 | ||
|
|
e58d5a54b1 | ||
|
|
2a95a5bf8a | ||
|
|
0c4e67a951 | ||
|
|
78d41b8e41 | ||
|
|
d5347176e1 | ||
|
|
6d91dad8e4 | ||
|
|
1dd5c97ae0 | ||
|
|
e80e5b0801 | ||
|
|
052d8ba879 | ||
|
|
d08ca9585a | ||
|
|
50c33dfcdf | ||
|
|
42c3c2b804 | ||
|
|
f83eeac5e2 | ||
|
|
d5517ede45 | ||
|
|
2339f1a01d | ||
|
|
6129924eb2 | ||
|
|
fce9ded30a | ||
|
|
8489907cf5 | ||
|
|
84ccde268e | ||
|
|
c191df5434 | ||
|
|
f49934a75b | ||
|
|
be3326d0d9 | ||
|
|
7919019b67 | ||
|
|
89d856a487 | ||
|
|
978a24ffab | ||
|
|
bd41cf377a | ||
|
|
3ee3f7e30b | ||
|
|
a032614dc7 | ||
|
|
0377d13d3d | ||
|
|
06fdfc2e14 | ||
|
|
510552c5e6 | ||
|
|
6675c273fd | ||
|
|
9131a69983 | ||
|
|
a8baf0ef45 | ||
|
|
5e4f32d808 | ||
|
|
5a8d18edf3 | ||
|
|
f34b238713 | ||
|
|
ad5c7d97ca | ||
|
|
c35f9c1315 | ||
|
|
e84ed61339 | ||
|
|
8265829105 | ||
|
|
a76d00a08e | ||
|
|
131864b940 | ||
|
|
d33a3f619a | ||
|
|
1a0e57d926 | ||
|
|
5e5845547e | ||
|
|
0de944be28 | ||
|
|
5df438fd2a | ||
|
|
6329f60dff | ||
|
|
0bf9a87293 | ||
|
|
6ae4c49c1a | ||
|
|
c683ae69af | ||
|
|
ab9b12e883 | ||
|
|
c41b506741 | ||
|
|
848180dc08 | ||
|
|
a7d39913fd | ||
|
|
85ca2152e4 | ||
|
|
b11b33b63c | ||
|
|
239f58b584 | ||
|
|
474cb48a14 | ||
|
|
577b0dfe1d | ||
|
|
2918e00d33 | ||
|
|
ffc930b871 | ||
|
|
55bffeba4a | ||
|
|
2adb14c320 | ||
|
|
0d4bf1c15a | ||
|
|
a3bf2bdd8c | ||
|
|
bc3a14cde2 | ||
|
|
b3d4e5cfdf | ||
|
|
885355ce53 | ||
|
|
a4d5b68134 | ||
|
|
67f2bc1385 | ||
|
|
d8fb2f9175 | ||
|
|
fcc8d59588 | ||
|
|
1f19ca1665 | ||
|
|
d04f7fc6e9 | ||
|
|
f9370718bc | ||
|
|
8d888b426f | ||
|
|
b6bd39660f | ||
|
|
959ba94eca | ||
|
|
d5cd1058ab | ||
|
|
80c7b04831 | ||
|
|
7017756140 | ||
|
|
d9a132b649 | ||
|
|
60a68aa136 | ||
|
|
464e4c1938 | ||
|
|
796f630a7c | ||
|
|
dc8f9e043d | ||
|
|
6afcf43ff2 | ||
|
|
e0ea7be499 | ||
|
|
4bf968a45a | ||
|
|
a86963d62d | ||
|
|
e40f9c9730 | ||
|
|
96be7c8990 | ||
|
|
3ced3f4c82 | ||
|
|
72eb240c3b | ||
|
|
20d247b3f7 | ||
|
|
318457cb2c | ||
|
|
336c9d6caa | ||
|
|
a7737912b0 | ||
|
|
f244aba03d | ||
|
|
b0c196cf82 | ||
|
|
cf5769753a | ||
|
|
d1217e84c7 | ||
|
|
172ce6c79f | ||
|
|
2746efeb25 | ||
|
|
b2e7fb01a9 | ||
|
|
9ef1545d06 | ||
|
|
fc1d58b631 | ||
|
|
2ebad55a59 | ||
|
|
d66a05dc41 | ||
|
|
998a5b080d | ||
|
|
39a0f54b0d | ||
|
|
024a823c78 | ||
|
|
1bbb424322 | ||
|
|
b04f04776b | ||
|
|
f0860ec145 | ||
|
|
658e0c6b03 | ||
|
|
49fa093767 | ||
|
|
51aed3ca0a | ||
|
|
b9cc914729 | ||
|
|
d084a37e11 | ||
|
|
cfd2c41c21 | ||
|
|
9dee4c158d | ||
|
|
6b8011228e | ||
|
|
2cd27d0d4a | ||
|
|
3dff09424d | ||
|
|
8e15a6e798 | ||
|
|
2756e12762 | ||
|
|
4eb71bcd14 | ||
|
|
2177df51a8 | ||
|
|
40c8e4832a | ||
|
|
3377bd4ae5 | ||
|
|
38c4f4f76c | ||
|
|
280c7c851f | ||
|
|
af9ccf0c09 | ||
|
|
e7cdac90f5 | ||
|
|
7aefcab8b0 | ||
|
|
514b90ac69 | ||
|
|
dbcb97949f | ||
|
|
76d559efc1 | ||
|
|
8d8584849c | ||
|
|
59a2cbefcb | ||
|
|
c568284f1b | ||
|
|
a8b26570e0 | ||
|
|
5a74b40ae4 | ||
|
|
04f595cd97 | ||
|
|
99a3102134 | ||
|
|
3a42979e53 | ||
|
|
912a53318e | ||
|
|
421401ae3f | ||
|
|
e15475449c | ||
|
|
31750b5ee5 | ||
|
|
bc92f6d4a4 | ||
|
|
1969e78d54 | ||
|
|
317f666d4c | ||
|
|
3fe68a051a | ||
|
|
5bfecc6152 | ||
|
|
e44ed2681f | ||
|
|
27a545f79d | ||
|
|
6b10f4241d | ||
|
|
73cc34467a | ||
|
|
ec1ff52dfb | ||
|
|
2761c40781 | ||
|
|
e981d90209 | ||
|
|
f965e1c3ff | ||
|
|
5b5a79b90b | ||
|
|
15729e9ea0 | ||
|
|
d9eb320bba | ||
|
|
cba016df74 | ||
|
|
cf36f5a23b | ||
|
|
34d2527606 | ||
|
|
8e8e695db9 | ||
|
|
9928f1b3c1 | ||
|
|
36c91c3984 | ||
|
|
b7b1714f32 | ||
|
|
2d1f1640f3 | ||
|
|
371a30f08b | ||
|
|
40dd23337c | ||
|
|
3114dfd39b | ||
|
|
04b34adec6 | ||
|
|
594e837440 | ||
|
|
c77fa12bda | ||
|
|
5674c9f4c2 | ||
|
|
bc01488a75 | ||
|
|
c3c6880382 | ||
|
|
1f2f5858c0 | ||
|
|
22259a322d | ||
|
|
06f59f4e8a | ||
|
|
2b7adeb220 | ||
|
|
05bd452f76 | ||
|
|
a6426d0ac5 | ||
|
|
5dd5c9c605 | ||
|
|
0e4b28ac25 | ||
|
|
62fecdcaa8 | ||
|
|
440558c44f | ||
|
|
fa9a92f214 | ||
|
|
c5af11f6bd | ||
|
|
ad3254deb6 | ||
|
|
fce04b9424 | ||
|
|
2d512c714b | ||
|
|
6298c586fd | ||
|
|
abca8535cf | ||
|
|
677374de86 | ||
|
|
92d015333a | ||
|
|
6c91304400 | ||
|
|
04b5002d8f | ||
|
|
9bde7a6daa | ||
|
|
33b54f3d0c | ||
|
|
c5b073702c | ||
|
|
e38bdd0d2d | ||
|
|
9c54e48194 | ||
|
|
12e048a7fb | ||
|
|
11400e43dc | ||
|
|
293b4960f3 | ||
|
|
22996854f7 | ||
|
|
71e58c768c | ||
|
|
bb3606b64f | ||
|
|
7a82777fc5 | ||
|
|
ec046411f1 | ||
|
|
ffaf968940 | ||
|
|
feb70aeb6b | ||
|
|
ded106b9e3 | ||
|
|
cfdcabc8b4 | ||
|
|
ab448988ff | ||
|
|
e3089d60ea | ||
|
|
34f892ae82 | ||
|
|
fbbf0ed41c | ||
|
|
66a8780fa2 | ||
|
|
2c610258d1 | ||
|
|
f7430d74a7 | ||
|
|
421d6db592 | ||
|
|
1d385fd35a | ||
|
|
7cb31581d5 | ||
|
|
768d550ee2 | ||
|
|
4fd7480557 | ||
|
|
7c0f0a59eb | ||
|
|
93aeee1611 | ||
|
|
86d9e1e816 | ||
|
|
73211c900b | ||
|
|
a19d4c19d3 | ||
|
|
cf3b7f2c16 | ||
|
|
2f21dd81b0 | ||
|
|
db3b3ed9eb | ||
|
|
9625d94aa0 | ||
|
|
5dec7d534f | ||
|
|
0317eec10d | ||
|
|
a34ab1d36e | ||
|
|
7144a0fb9b | ||
|
|
421924b73f | ||
|
|
466236e32f | ||
|
|
636f2d659f | ||
|
|
838a9c000c | ||
|
|
7a7c59e91a | ||
|
|
1ac6ab4428 | ||
|
|
dc3c82ad40 | ||
|
|
0f0a2dddfe | ||
|
|
c3f955d3f1 | ||
|
|
7b1832bd24 | ||
|
|
148c9533ae | ||
|
|
df96318662 | ||
|
|
d9d0be0256 | ||
|
|
de70d82cea | ||
|
|
81db44f584 | ||
|
|
d733d246f0 | ||
|
|
1c5170b759 | ||
|
|
367526f750 | ||
|
|
7a0830de15 | ||
|
|
a5fbfa3748 | ||
|
|
912a7a1781 | ||
|
|
563701fed8 | ||
|
|
414889e03b | ||
|
|
8d2de036d5 | ||
|
|
764761cfa5 | ||
|
|
90a0bb5acb | ||
|
|
0e4379f075 | ||
|
|
968c5dc4aa | ||
|
|
fedb15d5d0 | ||
|
|
ccc6bf05e8 | ||
|
|
a40e56bcb7 | ||
|
|
52453eaeff | ||
|
|
ff3337feed | ||
|
|
cd30a99fae | ||
|
|
081460e59d | ||
|
|
17a6d716ad | ||
|
|
d833de793d | ||
|
|
a6ff62c79c | ||
|
|
92457f7fab | ||
|
|
17fa2f4053 | ||
|
|
bce84376d3 | ||
|
|
be87cdddeb | ||
|
|
dc22661744 | ||
|
|
dc69d20ec9 | ||
|
|
22ed7ea3f2 | ||
|
|
2112fa919a | ||
|
|
f65702a8a8 | ||
|
|
68d19d4717 | ||
|
|
a6e0ec38e7 | ||
|
|
6415ae79be | ||
|
|
79b76fb5f4 | ||
|
|
42012389c4 | ||
|
|
4b5c43f080 | ||
|
|
d16e5090a6 | ||
|
|
ddbe680a58 | ||
|
|
2f50b57e76 | ||
|
|
dc291fa811 | ||
|
|
a1d499ed64 | ||
|
|
629f2e0043 | ||
|
|
5d321c4dac | ||
|
|
9d751e0c72 | ||
|
|
6f8fb561c6 | ||
|
|
1019872832 | ||
|
|
091471293d | ||
|
|
d7281286ba | ||
|
|
5cfda2803d | ||
|
|
9ee7a14685 | ||
|
|
40a6574b95 | ||
|
|
891e1388ba | ||
|
|
0fba7d41a6 | ||
|
|
1595fb8739 | ||
|
|
ebc852b358 | ||
|
|
5f5846a08b | ||
|
|
4d3d9cca2a | ||
|
|
7b77e9f9ae | ||
|
|
0f74e372ba | ||
|
|
a3b99dc309 | ||
|
|
d73d571f19 | ||
|
|
8a8ac1ffe6 | ||
|
|
d463c82c95 | ||
|
|
558af7a454 | ||
|
|
d57ebb3c94 | ||
|
|
855976df84 | ||
|
|
6c2a8d6047 | ||
|
|
38a856f7ff | ||
|
|
fb2a7d8cd1 | ||
|
|
b3f79e5b02 | ||
|
|
1722148333 | ||
|
|
27e96999cf | ||
|
|
7efa152418 | ||
|
|
2a45455c80 | ||
|
|
c06f49cb3e | ||
|
|
b837c68df8 | ||
|
|
f3ebb2e9ce | ||
|
|
df9f72134b | ||
|
|
4ff5004d7c | ||
|
|
bdf3d60148 | ||
|
|
e2c6546b61 | ||
|
|
1f0ee9837b | ||
|
|
71072f084e | ||
|
|
98651c2a14 | ||
|
|
74e5e5e182 | ||
|
|
16f9dbfe37 | ||
|
|
12f74de9b3 | ||
|
|
fec49e1e28 | ||
|
|
9dd9bb7092 | ||
|
|
9c07aab2d6 | ||
|
|
41a84cef23 | ||
|
|
8942e3e78d | ||
|
|
040fe58693 | ||
|
|
45398b7660 | ||
|
|
f3950a5a65 | ||
|
|
6f6c5129d1 | ||
|
|
139697b9cd | ||
|
|
ddd459426d | ||
|
|
3387c135ad | ||
|
|
73133b61fb | ||
|
|
ba0f594548 | ||
|
|
f4fa9bf51a | ||
|
|
9aea85a953 | ||
|
|
f878e5e635 | ||
|
|
4e2fb38d62 | ||
|
|
ee845376b5 | ||
|
|
75234da135 | ||
|
|
7dc9434aec | ||
|
|
5986cf4254 | ||
|
|
f6db636473 | ||
|
|
b30db08110 | ||
|
|
2dbef6105d | ||
|
|
eeee9625c1 | ||
|
|
76559b352b | ||
|
|
a3bf0d6002 | ||
|
|
96ae0dd23a | ||
|
|
9c9e04c5a0 | ||
|
|
15381c7832 | ||
|
|
175f929023 | ||
|
|
a23846b3a1 | ||
|
|
42c74e864a | ||
|
|
809f5d6d8e | ||
|
|
ff41a61432 | ||
|
|
28b531593a | ||
|
|
4d2f4f1be3 | ||
|
|
f97415755b | ||
|
|
67fa82cf14 | ||
|
|
1d38f5a4d5 | ||
|
|
e70f8471a8 | ||
|
|
093e737af9 | ||
|
|
1ae0b44bc5 | ||
|
|
17aeec59a3 | ||
|
|
b20507ef0a | ||
|
|
753995a91d | ||
|
|
67c67dd86d | ||
|
|
fdc0b283d7 | ||
|
|
2abc51789e | ||
|
|
1190b9c278 | ||
|
|
3a8e049093 | ||
|
|
f32a647a20 | ||
|
|
4645f512d1 | ||
|
|
3d89999a06 | ||
|
|
cb5c932447 | ||
|
|
ddf8aef4f7 | ||
|
|
2714ed503b | ||
|
|
78d96355dd | ||
|
|
bf429b7e87 | ||
|
|
2f44046622 | ||
|
|
fb106967bc | ||
|
|
32720bd372 | ||
|
|
fb1de5c1c6 | ||
|
|
69cb71ad7e | ||
|
|
0a9b98ed67 | ||
|
|
e1c4a5989b | ||
|
|
cac988f8e2 | ||
|
|
bbe92a3a40 | ||
|
|
a489550752 | ||
|
|
f1dbff1dd4 | ||
|
|
55ea0f398b | ||
|
|
38abb044d0 | ||
|
|
ca4e76b34f | ||
|
|
55e0086958 | ||
|
|
050ebb3b19 | ||
|
|
31f788eb5e | ||
|
|
f23b16db2b | ||
|
|
060f80c239 | ||
|
|
6c3d3b98b8 | ||
|
|
21dfbd0103 | ||
|
|
1a10569f6d | ||
|
|
33396ca9c1 | ||
|
|
36ba1ff790 | ||
|
|
fdfcff2bb5 | ||
|
|
c74c1a0c5f | ||
|
|
faca83e1e8 | ||
|
|
759ab54e59 | ||
|
|
3c61524f26 | ||
|
|
40013c2b61 | ||
|
|
5d5e7393f8 | ||
|
|
71c5511e6c | ||
|
|
ea83982062 | ||
|
|
cdbbdcba5f | ||
|
|
aeb708fe07 | ||
|
|
78c67ed53d | ||
|
|
ea37ee6cb3 | ||
|
|
2e67c5a045 | ||
|
|
752bc5a454 | ||
|
|
3a4bf8f213 | ||
|
|
bc20664c18 | ||
|
|
e27690e894 | ||
|
|
bac5ac18f7 | ||
|
|
e906b87450 | ||
|
|
9d0415f9e9 | ||
|
|
1d807911e4 | ||
|
|
f51f8ffe45 | ||
|
|
ea9930816f | ||
|
|
699cb92e86 | ||
|
|
f4f4f2d314 | ||
|
|
374472deda | ||
|
|
b27f0dd490 | ||
|
|
141d2b5626 | ||
|
|
cf0f44823a | ||
|
|
6355113af9 | ||
|
|
00ef7ec522 | ||
|
|
9497a4cb5a | ||
|
|
0f71667625 | ||
|
|
8b20e0166d | ||
|
|
567644dabd | ||
|
|
9ef8cdadf6 | ||
|
|
c911568306 | ||
|
|
ce02f798e4 | ||
|
|
21bb2fb03f | ||
|
|
11311d07e5 | ||
|
|
4426bf2615 | ||
|
|
515e973964 | ||
|
|
0a6b934ac1 | ||
|
|
b2e3013898 | ||
|
|
757cedc233 | ||
|
|
ab316b348a | ||
|
|
7b7c4bd116 | ||
|
|
82e751a153 | ||
|
|
c5c50a2141 | ||
|
|
9c32e630a0 | ||
|
|
02e26996c1 | ||
|
|
b25b72ae19 | ||
|
|
ff36375581 | ||
|
|
c9f5edbc1d | ||
|
|
ec00e0a952 | ||
|
|
51a4b86495 | ||
|
|
c3866b7d6b | ||
|
|
6dafca79be | ||
|
|
7aca8d2d1c | ||
|
|
7daef74fc6 | ||
|
|
58d0f3053d | ||
|
|
649f644c75 | ||
|
|
ad2a26611a | ||
|
|
89bb7d0211 | ||
|
|
b3564bf2b4 | ||
|
|
16f452cf2e | ||
|
|
56cedad707 | ||
|
|
4b6325908b | ||
|
|
460d8fc094 | ||
|
|
c435236ceb | ||
|
|
39254229a0 | ||
|
|
6182b205c8 | ||
|
|
e528b439bc | ||
|
|
629140d66c | ||
|
|
46ed4f2de1 | ||
|
|
46d55a8ada | ||
|
|
5e6af3d732 | ||
|
|
0d07c58989 | ||
|
|
6f80be0653 | ||
|
|
1916e688a6 | ||
|
|
07e56ddeb5 | ||
|
|
88c8009116 | ||
|
|
42d843297d | ||
|
|
15cdeeddaf | ||
|
|
3c13a265bc | ||
|
|
93eec9ac3c | ||
|
|
df7dbff683 | ||
|
|
2e6265963b | ||
|
|
b88b18df93 | ||
|
|
fbf5333b39 | ||
|
|
c6e3b490f5 | ||
|
|
19677f0622 | ||
|
|
a8932c2c25 | ||
|
|
f93e33d9de | ||
|
|
649e6efc4a | ||
|
|
a7d3619ec4 | ||
|
|
daca3a5fc9 | ||
|
|
135a52020c | ||
|
|
bf21ed7282 | ||
|
|
b5f65e3304 | ||
|
|
45400a1758 | ||
|
|
acc88bc2b4 | ||
|
|
98b0595275 | ||
|
|
4efecfdfa0 | ||
|
|
b5afb9d3ab | ||
|
|
6a1d58d4e7 | ||
|
|
d2a3db4c78 | ||
|
|
f207788c0a | ||
|
|
84b44069c8 | ||
|
|
09ed3f37db | ||
|
|
f444604e7c | ||
|
|
e1c9885566 | ||
|
|
4e7d905783 | ||
|
|
cb35e3a766 | ||
|
|
67fe5ed699 | ||
|
|
e863fd78d6 | ||
|
|
4ea2518e79 | ||
|
|
b508ab240f | ||
|
|
c545ec727c | ||
|
|
f290b9a145 | ||
|
|
fa1eb9bf25 | ||
|
|
3a32b83181 | ||
|
|
2e393b7d5c | ||
|
|
12e5b8124e | ||
|
|
26e939c1eb | ||
|
|
f09390a412 | ||
|
|
3067807802 | ||
|
|
60f4c9f5b3 | ||
|
|
b0ecafcb8d | ||
|
|
ddfb76e9e0 | ||
|
|
6f27f742fe | ||
|
|
c1a64301ce | ||
|
|
1ee690e87c | ||
|
|
28e0dbc02f | ||
|
|
a2604a36bc | ||
|
|
d031c5c7fa | ||
|
|
5d01b32c10 | ||
|
|
4fe651079c | ||
|
|
a573ea4aeb | ||
|
|
13704d9da5 | ||
|
|
73a1e137e6 | ||
|
|
0ec9c6c3cf | ||
|
|
4aa275e13c | ||
|
|
b557a73c3f | ||
|
|
b66098ea20 | ||
|
|
d0cefecd0d | ||
|
|
38a4e9806f | ||
|
|
3c64a57c84 | ||
|
|
36b0796976 | ||
|
|
3241d81ce5 | ||
|
|
d7a188fb34 | ||
|
|
5b217b2042 | ||
|
|
4cb2a92037 | ||
|
|
24d90c17c2 | ||
|
|
c95c6d72e9 | ||
|
|
99b174f495 | ||
|
|
5949ef0e2c | ||
|
|
d75d64df64 | ||
|
|
a5164df293 | ||
|
|
690113dd73 | ||
|
|
fe87160b19 | ||
|
|
fffe1be521 | ||
|
|
dc02bcee74 | ||
|
|
e7a9313135 | ||
|
|
5492845659 | ||
|
|
29dfe89137 | ||
|
|
0da3f84a2e | ||
|
|
c25b0c1a66 | ||
|
|
7c7314f673 | ||
|
|
869cc3d497 | ||
|
|
f315bf074b | ||
|
|
d33f9ddf44 | ||
|
|
fcf0c28132 | ||
|
|
b3e50cbb33 | ||
|
|
20cb709ae3 | ||
|
|
916a41ed60 | ||
|
|
9797a9993a | ||
|
|
04ce98148d | ||
|
|
34eb75f634 | ||
|
|
05b76281f7 | ||
|
|
4a35bcec21 | ||
|
|
518af0ef24 | ||
|
|
a155ec0599 | ||
|
|
80979cf4d0 | ||
|
|
a27ee2366e | ||
|
|
7bc56d7cfe | ||
|
|
088bdb3313 | ||
|
|
89d49cd925 | ||
|
|
84f8d8733e | ||
|
|
07f323222b | ||
|
|
a321bf1a90 | ||
|
|
92a0763a74 | ||
|
|
e878780808 | ||
|
|
cb5f1fa99d | ||
|
|
b55ac994ea | ||
|
|
3a8d6b80e0 | ||
|
|
3354a68373 | ||
|
|
edc894f6c7 | ||
|
|
f68714ec8e | ||
|
|
7be9352a3a | ||
|
|
3a782b6ace | ||
|
|
47d0b6fc14 | ||
|
|
8204351d67 | ||
|
|
4c3635a7c0 | ||
|
|
7ea43b0145 | ||
|
|
6afe6f4ecb | ||
|
|
273f2b61d0 | ||
|
|
0824873ffb | ||
|
|
8f99b13305 | ||
|
|
9253702966 | ||
|
|
3958450223 | ||
|
|
cc596ef011 | ||
|
|
8220b11770 | ||
|
|
62c54cd47c | ||
|
|
e34d0d69aa | ||
|
|
597e7e6f13 | ||
|
|
b460fd61bd | ||
|
|
c9b5df8184 | ||
|
|
341ecf3bbe | ||
|
|
b6b5144ddf | ||
|
|
deac5ff585 | ||
|
|
38a03ff2c8 | ||
|
|
527bed2b53 | ||
|
|
318166f8b0 | ||
|
|
394c751d7d | ||
|
|
86d707ad51 | ||
|
|
c3792db0e5 | ||
|
|
16e42e6d6d | ||
|
|
53c1674382 | ||
|
|
85917d4769 | ||
|
|
ae0d35c727 | ||
|
|
086dd284d6 | ||
|
|
8ba35a2dc3 | ||
|
|
48dfb1c8ca | ||
|
|
5a83a44112 | ||
|
|
58520859e5 | ||
|
|
4faba0fe8b | ||
|
|
c4b0155cc2 | ||
|
|
38b18202fc | ||
|
|
0f17a7d828 | ||
|
|
9da5b9f4bb | ||
|
|
a7fdc7b992 | ||
|
|
f519e22e6d | ||
|
|
ecac4dd72a | ||
|
|
b6c45485bc | ||
|
|
ec46932259 | ||
|
|
10182f1182 | ||
|
|
cfaec9d608 | ||
|
|
0f6157a49d | ||
|
|
1df6373cb1 | ||
|
|
ea32cd85fe | ||
|
|
716524c151 | ||
|
|
96722bba08 | ||
|
|
4e20a20927 | ||
|
|
a0d1004909 | ||
|
|
ccab950d16 | ||
|
|
2018c90ae2 | ||
|
|
793360c5bb | ||
|
|
d8b1a38350 | ||
|
|
499a3e3227 | ||
|
|
73a9fdca2a | ||
|
|
06dd9b8ed8 | ||
|
|
a86cb932cf | ||
|
|
2fae0a9f47 | ||
|
|
2ec9192010 | ||
|
|
202eff984d | ||
|
|
b172b538fc | ||
|
|
a34271adf9 | ||
|
|
2cf134668c | ||
|
|
b94b220156 | ||
|
|
26921cbe68 | ||
|
|
8844674825 | ||
|
|
c9fbe2cb92 | ||
|
|
2b941ccc93 | ||
|
|
ed080ae988 | ||
|
|
f31e89d5af | ||
|
|
52c311e47f | ||
|
|
5b54d4de7a | ||
|
|
96152f6577 | ||
|
|
e881b3c5de | ||
|
|
e86b507da7 | ||
|
|
2fc3a822c8 | ||
|
|
1b0e1edb08 | ||
|
|
d107b79c63 | ||
|
|
c5ab442f46 | ||
|
|
c5677df56e | ||
|
|
21ba0fb8a4 | ||
|
|
69319a0569 | ||
|
|
37d8e55991 | ||
|
|
8d20edb028 | ||
|
|
7564c4e7f4 | ||
|
|
26e02a9b8b | ||
|
|
25ec133574 | ||
|
|
d88ede92b9 | ||
|
|
5bafe9483d | ||
|
|
4e3663b4d4 | ||
|
|
12d7be7cad | ||
|
|
84f2595349 | ||
|
|
c11abc1134 | ||
|
|
f63bdda628 | ||
|
|
7d6a4f5204 | ||
|
|
f871869c79 | ||
|
|
8ebe72951f | ||
|
|
8d4b31a301 | ||
|
|
8912b3e035 | ||
|
|
f5d7057042 | ||
|
|
6d7e620430 | ||
|
|
0cc732dce3 | ||
|
|
8acd82aa0d | ||
|
|
7377c676fd | ||
|
|
9b3c4db10d | ||
|
|
49ada54f6d | ||
|
|
c43cdc5ac3 | ||
|
|
e1bd9976b3 | ||
|
|
a888564251 | ||
|
|
e2ccde6434 | ||
|
|
e88ff78816 | ||
|
|
5bc151fdca | ||
|
|
f0a5cdc6e4 | ||
|
|
85f53a4174 | ||
|
|
549ad272fc | ||
|
|
537348d995 | ||
|
|
d4580d1a31 | ||
|
|
93a103dde5 | ||
|
|
9e6ad97cfb | ||
|
|
8d995a8529 | ||
|
|
f869cd4b79 | ||
|
|
26b087c1b4 | ||
|
|
63bf4683c5 | ||
|
|
73456a68d7 | ||
|
|
aa6637b47a | ||
|
|
8f6e43fd66 | ||
|
|
ebce6ef263 | ||
|
|
c20a266a11 | ||
|
|
b825f141f3 | ||
|
|
7e5b3958cc | ||
|
|
deded848ee | ||
|
|
117161e6ff | ||
|
|
98d52edcc9 | ||
|
|
135d930c99 | ||
|
|
e6c78df975 | ||
|
|
3749797434 | ||
|
|
507ed25289 | ||
|
|
0d5e5f8dee | ||
|
|
3998933b30 | ||
|
|
271004bf60 | ||
|
|
c9e2d69bfb | ||
|
|
c194247dab | ||
|
|
a48420d85f | ||
|
|
5c66e8273b | ||
|
|
5992e629c3 | ||
|
|
765d67cd18 | ||
|
|
baf20af17f | ||
|
|
e482e7768b | ||
|
|
8682352edb | ||
|
|
ef1222ff31 | ||
|
|
0145f3a585 | ||
|
|
4a8bb56a1e | ||
|
|
ce5b02a9ad | ||
|
|
5c8ce41e12 | ||
|
|
a2586b8b06 | ||
|
|
1fd4485716 | ||
|
|
b029ab933e | ||
|
|
e0b28b6718 | ||
|
|
4dd2f3b7f7 | ||
|
|
e5f677803f | ||
|
|
a67f4db5e2 | ||
|
|
8a01dc7f4c | ||
|
|
e107f115e2 | ||
|
|
af8af4881b | ||
|
|
d871dad85f | ||
|
|
5b83d30887 | ||
|
|
2e3b8a03aa | ||
|
|
d924b7d283 | ||
|
|
e0425ad3e1 | ||
|
|
5dced02a20 | ||
|
|
e642f128ae | ||
|
|
7d0ae151e8 | ||
|
|
f945e284e1 | ||
|
|
7166efef08 | ||
|
|
a81689e902 | ||
|
|
0a0418b973 | ||
|
|
f81f432af5 | ||
|
|
dda017df23 | ||
|
|
46be5eac7d | ||
|
|
e4076d14c0 | ||
|
|
20fc412765 | ||
|
|
c251681a40 | ||
|
|
9c25e15e92 | ||
|
|
bcbf0de240 | ||
|
|
1ef7f4dbad | ||
|
|
9db969e94e | ||
|
|
4f27f4cf79 | ||
|
|
b7c46909cd | ||
|
|
953f9af419 | ||
|
|
6ea32873df | ||
|
|
80d9d288c2 | ||
|
|
dd55a80430 | ||
|
|
f7cdb12101 | ||
|
|
3b8783ec0e | ||
|
|
4d2a8a80d4 | ||
|
|
d51a3e9b48 | ||
|
|
cadd29e147 |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
.git
|
||||
.worktrees
|
||||
.bun-cache
|
||||
.bun
|
||||
.tmp
|
||||
**/.tmp
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.webp
|
||||
*.gif
|
||||
*.mp4
|
||||
*.mov
|
||||
*.wav
|
||||
*.mp3
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
.turbo
|
||||
**/.turbo
|
||||
.cache
|
||||
**/.cache
|
||||
.next
|
||||
**/.next
|
||||
coverage
|
||||
**/coverage
|
||||
*.log
|
||||
tmp
|
||||
**/tmp
|
||||
|
||||
# build artifacts
|
||||
dist
|
||||
**/dist
|
||||
apps/macos/.build
|
||||
apps/ios/build
|
||||
**/*.trace
|
||||
|
||||
# large app trees not needed for CLI build
|
||||
apps/
|
||||
assets/
|
||||
Peekaboo/
|
||||
Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
+344
-5
@@ -7,20 +7,56 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: [node, bun]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Node version
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
# bun.sh downloads currently fail with:
|
||||
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
@@ -41,11 +77,314 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Lint
|
||||
- name: Lint (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
- name: Test (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
- name: Build (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm build
|
||||
|
||||
- name: Protocol check (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm protocol:check
|
||||
|
||||
- name: Lint (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx biome check src
|
||||
|
||||
- name: Test (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx vitest run
|
||||
|
||||
- name: Build (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx tsc -p tsconfig.json
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: SwiftLint
|
||||
run: swiftlint --config .swiftlint.yml
|
||||
|
||||
- name: SwiftFormat (lint mode)
|
||||
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift tests (coverage)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: brew install swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: iOS tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
DEST_ID="$(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
def sh(args: list[str]) -> str:
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
# Prefer an already-created iPhone simulator if it exists.
|
||||
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for runtime, devs in (devices.get("devices") or {}).items():
|
||||
for dev in devs or []:
|
||||
if not dev.get("isAvailable"):
|
||||
continue
|
||||
name = str(dev.get("name") or "")
|
||||
udid = str(dev.get("udid") or "")
|
||||
if not udid or not name.startswith("iPhone"):
|
||||
continue
|
||||
candidates.append((name, udid))
|
||||
|
||||
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
|
||||
if candidates:
|
||||
print(candidates[0][1])
|
||||
sys.exit(0)
|
||||
|
||||
# Otherwise, create one from the newest available iOS runtime.
|
||||
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
|
||||
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
|
||||
if not ios:
|
||||
print("No available iOS runtimes found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def version_key(rt: dict) -> tuple[int, ...]:
|
||||
parts: list[int] = []
|
||||
for p in str(rt.get("version") or "0").split("."):
|
||||
try:
|
||||
parts.append(int(p))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
ios.sort(key=version_key, reverse=True)
|
||||
runtime = ios[0]
|
||||
runtime_id = str(runtime.get("identifier") or "")
|
||||
if not runtime_id:
|
||||
print("Missing iOS runtime identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
supported = runtime.get("supportedDeviceTypes") or []
|
||||
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
|
||||
if not iphones:
|
||||
print("No iPhone device types for iOS runtime.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
iphones.sort(
|
||||
key=lambda dt: (
|
||||
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
|
||||
str(dt.get("name") or ""),
|
||||
)
|
||||
)
|
||||
device_type_id = str(iphones[0].get("identifier") or "")
|
||||
if not device_type_id:
|
||||
print("Missing iPhone device type identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
||||
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
||||
if not udid:
|
||||
print("Failed to create iPhone simulator.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(udid)
|
||||
PY
|
||||
)"
|
||||
echo "Using iOS Simulator id: $DEST_ID"
|
||||
xcodebuild test \
|
||||
-project apps/ios/Clawdis.xcodeproj \
|
||||
-scheme Clawdis \
|
||||
-destination "platform=iOS Simulator,id=$DEST_ID" \
|
||||
-resultBundlePath "$RESULT_BUNDLE_PATH" \
|
||||
-enableCodeCoverage YES
|
||||
|
||||
- name: iOS coverage summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
|
||||
|
||||
- name: iOS coverage gate (43%)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
target_name = "Clawdis.app"
|
||||
minimum = 0.43
|
||||
|
||||
report = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
|
||||
target_coverage = None
|
||||
for target in report.get("targets", []):
|
||||
if target.get("name") == target_name:
|
||||
target_coverage = float(target["lineCoverage"])
|
||||
break
|
||||
|
||||
if target_coverage is None:
|
||||
print(f"Could not find coverage for target: {target_name}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
|
||||
if target_coverage + 1e-12 < minimum:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Android unit tests + debug build
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
+43
@@ -1,6 +1,49 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdisKit/.build/
|
||||
bin/
|
||||
bin/clawdis-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/ClawdisKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
apps/ios/fastlane/report.xml
|
||||
apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
@@ -0,0 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
@@ -0,0 +1,51 @@
|
||||
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
||||
|
||||
--swiftversion 6.2
|
||||
|
||||
# Self handling
|
||||
--self insert
|
||||
--selfrequired
|
||||
|
||||
# Imports / extensions
|
||||
--importgrouping testable-bottom
|
||||
--extensionacl on-declarations
|
||||
|
||||
# Indentation
|
||||
--indent 4
|
||||
--indentcase false
|
||||
--ifdef no-indent
|
||||
--xcodeindentation enabled
|
||||
|
||||
# Line breaks
|
||||
--linebreaks lf
|
||||
--maxwidth 120
|
||||
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
|
||||
# Wrapping
|
||||
--wraparguments before-first
|
||||
--wrapparameters before-first
|
||||
--wrapcollections before-first
|
||||
--closingparen same-line
|
||||
|
||||
# Organization
|
||||
--organizetypes class,struct,enum,extension
|
||||
--extensionmark "MARK: - %t + %p"
|
||||
--marktypes always
|
||||
--markextensions always
|
||||
--structthreshold 0
|
||||
--enumthreshold 0
|
||||
|
||||
# Other
|
||||
--stripunusedargs closure-only
|
||||
--header ignore
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
||||
|
||||
included:
|
||||
- apps/macos/Sources
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.build"
|
||||
- "**/.swiftpm"
|
||||
- "**/DerivedData"
|
||||
- "**/Generated"
|
||||
- "**/Resources"
|
||||
- "**/Package.swift"
|
||||
- "**/Tests/Resources"
|
||||
- node_modules
|
||||
- dist
|
||||
- coverage
|
||||
- "*.playground"
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
- unused_import
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- contains_over_first_not_nil
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- fallthrough
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- pattern_matching_keywords
|
||||
- private_outlet
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
|
||||
disabled_rules:
|
||||
# SwiftFormat handles these
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- trailing_comma
|
||||
- vertical_whitespace
|
||||
- indentation_width
|
||||
|
||||
# Style exclusions
|
||||
- explicit_self
|
||||
- identifier_name
|
||||
- file_header
|
||||
- explicit_top_level_acl
|
||||
- explicit_acl
|
||||
- explicit_type_interface
|
||||
- missing_docs
|
||||
- required_deinit
|
||||
- prefer_nimble
|
||||
- quick_discouraged_call
|
||||
- quick_discouraged_focused_test
|
||||
- quick_discouraged_pending_test
|
||||
- anonymous_argument_in_multiline_closure
|
||||
- no_extension_access_modifier
|
||||
- no_grouping_extension
|
||||
- switch_case_on_newline
|
||||
- strict_fileprivate
|
||||
- extension_access_modifier
|
||||
- convenience_type
|
||||
- no_magic_numbers
|
||||
- one_declaration_per_file
|
||||
- vertical_whitespace_between_cases
|
||||
- vertical_whitespace_closing_braces
|
||||
- superfluous_else
|
||||
- number_separator
|
||||
- prefixed_toplevel_constant
|
||||
- opening_brace
|
||||
- trailing_closure
|
||||
- contrasted_opening_brace
|
||||
- sorted_imports
|
||||
- redundant_type_annotation
|
||||
- shorthand_optional_binding
|
||||
- untyped_error_in_catch
|
||||
- file_name
|
||||
- todo
|
||||
|
||||
force_cast: warning
|
||||
force_try: warning
|
||||
|
||||
type_name:
|
||||
min_length:
|
||||
warning: 2
|
||||
error: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
error: 80
|
||||
|
||||
function_body_length:
|
||||
warning: 150
|
||||
error: 300
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
type_body_length:
|
||||
warning: 800
|
||||
error: 1200
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 20
|
||||
error: 120
|
||||
|
||||
large_tuple:
|
||||
warning: 4
|
||||
error: 5
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 4
|
||||
error: 6
|
||||
function_level:
|
||||
warning: 5
|
||||
error: 7
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 250
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
reporter: "xcode"
|
||||
@@ -1,13 +1,13 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Claude config). Built output lives in `dist/`.
|
||||
- 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/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -16,22 +16,63 @@
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Environment: copy `.env.example`; set Twilio creds and WhatsApp sender (`TWILIO_WHATSAPP_FROM`).
|
||||
- Web provider stores creds at `~/.warelay/credentials/`; rerun `warelay login` if logged out.
|
||||
- Media hosting relies on Tailscale Funnel when using Twilio; use `warelay webhook --ingress tailscale` or `--serve-media` for local hosting.
|
||||
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
||||
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Agent-Specific Notes
|
||||
- If the relay is running in tmux (`warelay-relay`), restart it after code changes: kill pane/session and run `pnpm warelay relay --verbose` inside tmux. Check tmux before editing; keep the watcher healthy if you start it.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **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 subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- 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.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
|
||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- 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 `clawdis-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 sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
|
||||
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdis send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdis bug.
|
||||
+577
-26
@@ -1,34 +1,585 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 — 2025-11-25
|
||||
## Unreleased
|
||||
|
||||
### CLI & Providers
|
||||
- Bundles a single `warelay` CLI with commands for `send`, `relay`, `status`, `webhook`, `login`, and tmux helpers `relay:tmux` / `relay:tmux:attach` (see `src/cli/program.ts`); `webhook` accepts `--ingress tailscale|none`.
|
||||
- Supports two messaging backends: **Twilio** (default) and **personal WhatsApp Web**; `relay --provider auto` selects Web when a cached login exists, otherwise falls back to Twilio polling (`provider-web.ts`, `cli/program.ts`).
|
||||
- `send` can target either provider, optionally wait for delivery status (Twilio only), output JSON, dry-run payloads, and attach media (`commands/send.ts`).
|
||||
- `status` merges inbound + outbound Twilio traffic with formatted lines or JSON output (`commands/status.ts`, `twilio/messages.ts`).
|
||||
### Breaking
|
||||
- Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`.
|
||||
- Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
|
||||
### Webhook, Funnel & Port Management
|
||||
- `webhook` starts an Express server for inbound Twilio callbacks, logs requests, and optionally auto-replies with static text or config-driven replies (`twilio/webhook.ts`, `commands/webhook.ts`).
|
||||
- `webhook --ingress tailscale` automates end-to-end webhook setup: ensures required binaries, enables Tailscale Funnel, starts the webhook on the chosen port/path, discovers the WhatsApp sender SID, and updates Twilio webhook URLs with multiple fallbacks (`commands/up.ts`, `infra/tailscale.ts`, `twilio/update-webhook.ts`, `twilio/senders.ts`).
|
||||
- Guardrails detect busy ports with helpful diagnostics and aborts when conflicts are found (`infra/ports.ts`).
|
||||
### Features
|
||||
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
|
||||
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
|
||||
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
|
||||
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
||||
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
|
||||
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
|
||||
|
||||
### Auto-Reply Engine
|
||||
- Configurable via `~/.warelay/warelay.json` (JSON5) with allowlist support, text or command-driven replies, templating (`{{Body}}`, `{{From}}`, `{{MediaPath}}`, etc.), optional body prefixes, and per-sender or global conversation sessions with `/new` resets and idle expiry (`auto-reply/reply.ts`, `config/config.ts`, `config/sessions.ts`, `auto-reply/templating.ts`).
|
||||
- Command replies run through a process-wide FIFO queue to avoid concurrent executions across webhook, poller, and web listener flows (`process/command-queue.ts`); verbose mode surfaces wait times.
|
||||
- Claude CLI integration auto-injects identity, output-format flags, session args, and parses JSON output while preserving metadata (`auto-reply/claude.ts`, `auto-reply/reply.ts`).
|
||||
- Typing indicators fire before replies for Twilio, and Web provider sends “composing/available” presence when possible (`twilio/typing.ts`, `provider-web.ts`).
|
||||
### Fixes
|
||||
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
|
||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
||||
- Block streaming: default to text_end and suppress duplicate block sends while in-flight.
|
||||
- Block streaming: avoid duplicate block chunks when providers repeat full content on text_end.
|
||||
- Block streaming: drop final payloads after soft chunking to keep Discord order intact.
|
||||
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
|
||||
- Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna.
|
||||
- Control UI: stream live tool output cards in Chat (agent events include sessionKey).
|
||||
- Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference.
|
||||
- Agent tools: scope the Discord tool to Discord surface runs.
|
||||
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
|
||||
- Agent tools: emit verbose tool summaries at tool start (no debounce).
|
||||
- Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log.
|
||||
- Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability.
|
||||
- Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.
|
||||
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
|
||||
- macOS onboarding: increase window height so the permissions page fits without scrolling.
|
||||
- Thinking: default to low for reasoning-capable models when no /think or config default is set.
|
||||
- Logging: decouple file log levels from console verbosity; verbose-only details are captured when `logging.level` is debug/trace.
|
||||
- Build: fix regex literal in tool-meta path detection (watch build error).
|
||||
- Build: require AVX2 Bun for x86_64 relay packaging (reject baseline builds).
|
||||
- Auto-reply: add run-level telemetry + typing TTL guardrails to diagnose stuck replies.
|
||||
- WhatsApp: honor per-group mention gating overrides when group ids are stored as session keys.
|
||||
- Dependencies: bump pi-mono packages to 0.32.3.
|
||||
|
||||
### Media Pipeline
|
||||
- `send --media` works on both providers: Web accepts local paths or URLs; Twilio requires HTTPS and transparently hosts local files (≤5 MB) via the Funnel/webhook media endpoint, auto-spawning a short-lived media server when `--serve-media` is requested (`commands/send.ts`, `media/host.ts`, `media/server.ts`).
|
||||
- Auto-replies may include `mediaUrl` from config or command output (`MEDIA:` token extraction) and will host local media when needed before sending (`auto-reply/reply.ts`, `media/parse.ts`, `media/host.ts`).
|
||||
- Inbound media from Twilio or Web is downloaded to `~/.warelay/media` with TTL cleanup and passed to commands via `MediaPath`/`MediaType` for richer prompts (`twilio/webhook.ts`, `provider-web.ts`, `media/store.ts`).
|
||||
### Docs
|
||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
|
||||
- Skills: document Discord `sendMessage` media attachments and `to` format clarification.
|
||||
- Skills: add tmux skill + interactive coding guidance in coding-agent.
|
||||
- Gateway: document port configuration + multi-instance isolation.
|
||||
- Onboarding/Config: add protocol notes for wizard + schema RPC.
|
||||
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
|
||||
|
||||
### Relay & Monitoring
|
||||
- `relay` polls Twilio on an interval with exponential-backoff resilience, auto-replying to inbound messages, or listens live via WhatsApp Web with automatic read receipts and presence updates (`cli/program.ts`, `twilio/monitor.ts`, `provider-web.ts`).
|
||||
- `send` + `waitForFinalStatus` polls Twilio until a terminal delivery state (delivered/read) or timeout, with clear failure surfaces (`twilio/send.ts`).
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
### Developer & Ops Ergonomics
|
||||
- `relay:tmux` helper restarts/attaches to a dedicated `warelay-relay` tmux session for long-running relays (`cli/relay_tmux.ts`).
|
||||
- Environment validation enforces Twilio credentials early and supports either auth token or API key/secret pairs (`env.ts`).
|
||||
- Shared logging utilities, binary checks, and runtime abstractions keep CLI output consistent (`globals.ts`, `logger.ts`, `infra/binaries.ts`).
|
||||
### Fixed
|
||||
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
|
||||
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
|
||||
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
|
||||
- Tests: cover read tool image metadata + text output.
|
||||
- Tests: add queue mode coverage (collect/followup + directive parsing).
|
||||
|
||||
### Breaking
|
||||
- Skills config schema moved under `skills.*`:
|
||||
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
||||
- `skillsInstall.*` → `skills.install.*`
|
||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
||||
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
||||
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
||||
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
|
||||
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
|
||||
|
||||
### Features
|
||||
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.
|
||||
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
|
||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
|
||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
|
||||
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
||||
- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.
|
||||
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
|
||||
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
||||
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
||||
- UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).
|
||||
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
||||
- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).
|
||||
- Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).
|
||||
- Skills: add Trello skill for board/list/card management (thanks @clawd).
|
||||
- Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).
|
||||
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
||||
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
||||
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
|
||||
- CLI: add ASCII banner header to wizard entry points.
|
||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
||||
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
|
||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
||||
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
|
||||
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
|
||||
- Skills: allow `bun` as a node manager for skill installs.
|
||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
||||
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
||||
- Tests: add a Docker-based onboarding E2E harness.
|
||||
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
||||
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
|
||||
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
|
||||
|
||||
### Fixes
|
||||
- macOS codesign: make ad-hoc signing opt-in with loud warnings and document TCC permission fragility — thanks @mcinteerj.
|
||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
|
||||
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
|
||||
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
|
||||
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
|
||||
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
|
||||
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
||||
- Agent prompt: remove hardcoded user name in system prompt example.
|
||||
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
|
||||
- Control UI: refine Web Chat session selector styling (chevron spacing + background).
|
||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
||||
- Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).
|
||||
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
||||
- WhatsApp auto-reply: default to self-only when no config is present.
|
||||
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
||||
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.
|
||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
||||
- Discord: include author tag + id in group context `[from:]` lines for ping-ready replies (thanks @thewilloftheshadow).
|
||||
- Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).
|
||||
- Discord: preserve newlines when stripping reply tags from agent output.
|
||||
- Gateway: fix TypeScript build by aligning hook mapping `channel` types and removing a dead Group DM branch in Discord monitor.
|
||||
- Skills: switch imsg installer to brew tap formula.
|
||||
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
|
||||
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
|
||||
- Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.
|
||||
- CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.
|
||||
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
||||
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
||||
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
||||
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
||||
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
||||
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
||||
- macOS: keep config writes on the main actor to satisfy Swift concurrency rules.
|
||||
- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect.
|
||||
- macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.
|
||||
- macOS menu: tighten session row padding and time out session preview loading with cached fallback.
|
||||
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
|
||||
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
|
||||
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
|
||||
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
|
||||
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
|
||||
- Camera snap: add `delayMs` parameter (default 2000ms on macOS) to improve exposure reliability.
|
||||
- Camera: add `camera.list` and optional `deviceId` selection for snaps/clips.
|
||||
- Tests: cover camera device selection params in CLI + agent tools.
|
||||
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
|
||||
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
|
||||
- Telegram: honor per-group mention gating defaults/overrides via `telegram.groups` and `"*"` defaults (thanks @joshp123).
|
||||
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
|
||||
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
|
||||
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
||||
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
|
||||
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
|
||||
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
||||
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
|
||||
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
||||
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
|
||||
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
|
||||
- Signal: fix daemon startup race (wait for `/api/v1/check`) and normalize JSON-RPC `version` probe parsing.
|
||||
- Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.
|
||||
- Docs: refresh the CLI wizard guide and highlight onboarding in the README.
|
||||
- CLI: tighten onboarding prompt typing to keep bun builds green.
|
||||
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
|
||||
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
|
||||
- macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).
|
||||
- macOS Debug: add an icon for the App Logging submenu.
|
||||
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
|
||||
- macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.
|
||||
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
||||
- macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.
|
||||
- macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.
|
||||
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
||||
- WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.
|
||||
- Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).
|
||||
- Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.
|
||||
- Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.
|
||||
- iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.
|
||||
- ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.
|
||||
- Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).
|
||||
- Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).
|
||||
- Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.
|
||||
- Chat UI: user bubbles use `ui.seamColor` (fallback to a calmer default blue).
|
||||
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
|
||||
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
|
||||
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
|
||||
- Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.
|
||||
- Control UI: soften chat bubble text opacity for calmer readability.
|
||||
- macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.
|
||||
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
|
||||
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
||||
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
|
||||
- iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.
|
||||
- iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.
|
||||
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
||||
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
||||
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
||||
- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.
|
||||
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
|
||||
- macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries).
|
||||
- macOS menu: device list now shows connected nodes only.
|
||||
- macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.
|
||||
- macOS menu: split device platform/version across first and second rows for better fit.
|
||||
- macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.
|
||||
- Canvas A2UI: polish the debug status HUD styling.
|
||||
- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.
|
||||
- iOS Talk Mode: avoid audio tap queue assertions when starting recognition.
|
||||
- macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).
|
||||
- macOS remote: harden SSH tunnel recovery/logging, honor `gateway.remote.url` port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.
|
||||
- iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.
|
||||
- macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).
|
||||
- iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.
|
||||
- iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.
|
||||
- iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.
|
||||
- iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.
|
||||
- iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.
|
||||
- macOS menu: top status line now shows pending node pairing approvals (incl. repairs).
|
||||
- CLI: avoid spurious gateway close errors after successful request/response cycles.
|
||||
- Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.
|
||||
- Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.
|
||||
- Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.
|
||||
|
||||
## 2.0.0-beta4 — 2025-12-27
|
||||
|
||||
### Fixes
|
||||
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
||||
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered.
|
||||
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
|
||||
- macOS local mode now waits for the gateway to be ready before configuring the control channel, avoiding false “no connection” flashes.
|
||||
- WhatsApp watchdog now forces a reconnect even if the socket close event stalls (force-close to unblock reconnect loop).
|
||||
- Gateway presence now reports macOS product version (via `sw_vers`) instead of Darwin kernel version.
|
||||
|
||||
## 2.0.0-beta3 — 2025-12-27
|
||||
|
||||
### Highlights
|
||||
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
||||
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
|
||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
||||
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
|
||||
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
|
||||
- Gateway in-process restart: `gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
|
||||
|
||||
### Breaking
|
||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
||||
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
|
||||
|
||||
### Fixes
|
||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
|
||||
- Heartbeats now skip WhatsApp delivery when the web provider is inactive or unlinked (instead of logging “no active gateway listener”).
|
||||
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
|
||||
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
|
||||
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
|
||||
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
|
||||
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
|
||||
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
|
||||
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
|
||||
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
|
||||
- Gateway auth no longer supports PAM/system mode; use token or shared password.
|
||||
- Tailscale Funnel now requires password auth (no token-only public exposure).
|
||||
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
|
||||
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
|
||||
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
|
||||
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
|
||||
- Streamed `<think>` segments are stripped before partial replies are emitted.
|
||||
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
|
||||
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
|
||||
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
|
||||
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
|
||||
- Model switches now enqueue a system event so the next run knows the active model.
|
||||
- `/model status` now lists available models (same as `/model`).
|
||||
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
|
||||
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
|
||||
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
|
||||
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
|
||||
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
|
||||
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
|
||||
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
|
||||
- CLI now hints when Peekaboo is unauthorized.
|
||||
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
|
||||
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
|
||||
|
||||
### Providers & Routing
|
||||
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
|
||||
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
|
||||
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
|
||||
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
|
||||
|
||||
### macOS app
|
||||
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
||||
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
||||
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
||||
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
|
||||
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
|
||||
- Connections now include Discord provider status + configuration UI.
|
||||
- Menu bar gains an Allow Camera toggle alongside Canvas.
|
||||
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
||||
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
||||
- OAuth storage moved; legacy session syncing metadata removed.
|
||||
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
|
||||
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
|
||||
- Menu hover highlights now span the full width (including submenu arrows).
|
||||
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
|
||||
- Menu width no longer grows on hover when moving the mouse across rows.
|
||||
- Context usage bars now have higher contrast in light mode.
|
||||
- macOS node timeouts now share a single async timeout helper for consistent behavior.
|
||||
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
|
||||
|
||||
### Nodes & Canvas
|
||||
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
|
||||
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
|
||||
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
|
||||
- `nodes rename` lets you override paired node display names without editing JSON.
|
||||
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
|
||||
|
||||
### Logging & Observability
|
||||
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
|
||||
- WhatsApp console output streamlined; chalk/tslog typing fixes.
|
||||
|
||||
### Web UI
|
||||
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
|
||||
|
||||
### Build, Dev, Docs
|
||||
- Notarization flow added for macOS release artifacts; packaging scripts updated.
|
||||
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
|
||||
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
|
||||
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
|
||||
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
|
||||
|
||||
### Tests
|
||||
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
|
||||
- Added gateway webhook coverage (auth, validation, and summary posting).
|
||||
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
|
||||
|
||||
## 2.0.0-beta2 — 2025-12-21
|
||||
|
||||
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
|
||||
|
||||
### Highlights
|
||||
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
|
||||
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
|
||||
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
|
||||
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
|
||||
|
||||
### Gateway & providers
|
||||
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
|
||||
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
|
||||
- Telegram: normalized chat IDs with clearer error reporting.
|
||||
|
||||
### Canvas & browser control
|
||||
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
|
||||
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
|
||||
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
|
||||
|
||||
### macOS UI polish
|
||||
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
|
||||
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
|
||||
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
|
||||
|
||||
### Build, CI, deps
|
||||
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
|
||||
|
||||
### Docs
|
||||
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
|
||||
|
||||
## 2.0.0-beta1 — 2025-12-19
|
||||
|
||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||
|
||||
### Bug Fixes
|
||||
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
||||
|
||||
### Breaking
|
||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||
- Pi only: only the embedded Pi runtime remains, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||
|
||||
### Gateway, nodes, and automation
|
||||
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
||||
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
||||
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
||||
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
||||
|
||||
### macOS companion app
|
||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
||||
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
||||
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
||||
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
||||
|
||||
### iOS node
|
||||
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
||||
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
||||
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
||||
|
||||
### WhatsApp & agent experience
|
||||
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
||||
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
|
||||
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
|
||||
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
|
||||
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
|
||||
|
||||
### CLI, RPC, and health
|
||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||
|
||||
### Security & hardening
|
||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
||||
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
||||
|
||||
### Docs
|
||||
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
||||
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
||||
|
||||
## 1.5.0 — 2025-12-05
|
||||
|
||||
### Breaking
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains and related CLI helpers have been removed.
|
||||
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||
|
||||
### Changes
|
||||
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
|
||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
|
||||
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
|
||||
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
|
||||
|
||||
## 1.4.1 — 2025-12-04
|
||||
|
||||
### Changes
|
||||
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
|
||||
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
|
||||
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
|
||||
|
||||
## 1.4.0 — 2025-12-03
|
||||
|
||||
### Highlights
|
||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `agent.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||
|
||||
### Reliability & UX
|
||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
||||
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
||||
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
||||
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
||||
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
||||
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
||||
|
||||
### Security / Hardening
|
||||
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
|
||||
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
||||
|
||||
### Bug Fixes
|
||||
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
||||
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
||||
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||
|
||||
### Testing
|
||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||
|
||||
## 1.3.0 — 2025-12-02
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||
|
||||
### Bug Fixes
|
||||
- Empty `result` fields no longer leak raw JSON to users.
|
||||
- Heartbeat alerts now honor `responsePrefix`.
|
||||
- Command failures return user-friendly messages.
|
||||
- Test session isolation to avoid touching real `sessions.json`.
|
||||
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
|
||||
- Web send respects media kind (image/audio/video/document) with correct limits.
|
||||
|
||||
### Changes
|
||||
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
|
||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||
- Early `allowFrom` filtering before decryption.
|
||||
- Same-phone mode with echo detection and optional message prefix marker.
|
||||
|
||||
## 1.2.2 — 2025-11-28
|
||||
|
||||
### Changes
|
||||
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
|
||||
|
||||
## 1.2.1 — 2025-11-28
|
||||
|
||||
### Changes
|
||||
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
||||
|
||||
### Planned / in progress (from prior notes)
|
||||
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
|
||||
- Heartbeat delivery preview (Claude path) dry-run.
|
||||
- Simulated inbound hook for local testing.
|
||||
|
||||
## 1.2.0 — 2025-11-27
|
||||
|
||||
### Changes
|
||||
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
|
||||
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
|
||||
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
|
||||
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
|
||||
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
|
||||
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
|
||||
|
||||
## 1.1.0 — 2025-11-26
|
||||
|
||||
### Changes
|
||||
- Web auto-replies resize/recompress media and honor `agent.mediaMaxMb`.
|
||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
|
||||
- Optional audio transcription via external CLI.
|
||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||
- Web provider refactor; logout command; web-only gateway start helper.
|
||||
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
||||
- Relay help prints effective heartbeat/backoff when in web mode.
|
||||
|
||||
## 1.0.4 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
|
||||
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
|
||||
|
||||
## 0.1.3 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
||||
- Added tests for timeout fallback and partial-output truncation.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Contributing to Clawdis
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
- **GitHub:** https://github.com/steipete/clawdis
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
## How to Contribute
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/steipete/clawdis/discussions) or ask in Discord first
|
||||
3. **Questions** → Discord #setup-help
|
||||
|
||||
## Before You PR
|
||||
- Test locally with your Clawdis instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
|
||||
Please include in your PR:
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
Submodule
+1
Submodule Peekaboo added at 9db365b73c.
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,131 +1,246 @@
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.
|
||||
[](https://github.com/steipete/warelay/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/warelay)
|
||||
[](LICENSE)
|
||||
# 🦞 CLAWDIS — Personal AI Assistant
|
||||
|
||||
Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||
</p>
|
||||
|
||||
## Quick Start (pick your engine)
|
||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||
<p align="center">
|
||||
<strong>EXFOLIATE! EXFOLIATE!</strong>
|
||||
</p>
|
||||
|
||||
**A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)**
|
||||
1. Link your account: `warelay login` (scan the QR).
|
||||
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"` (add `--provider web` if you want to force the web session).
|
||||
3. Stay online & auto-reply: `warelay relay --verbose` (defaults to Web when logged in, falls back to Twilio otherwise).
|
||||
<p align="center">
|
||||
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></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>
|
||||
|
||||
**B) Twilio WhatsApp number (for delivery status + webhooks)**
|
||||
1. Copy `.env.example` → `.env`; set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` **or** `TWILIO_API_KEY`/`TWILIO_API_SECRET`, and `TWILIO_WHATSAPP_FROM=whatsapp:+19995550123` (optional `TWILIO_SENDER_SID`).
|
||||
2. Send a message: `warelay send --to +12345550000 --message "Hi from warelay"`.
|
||||
3. Receive replies:
|
||||
- Polling (no ingress): `warelay relay --provider twilio --interval 5 --lookback 10`
|
||||
- Webhook + public URL via Tailscale Funnel: `warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose`
|
||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
> Already developing locally? You can still run `pnpm install` and `pnpm warelay ...` from the repo, but end users only need the npm package.
|
||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
## Main Features
|
||||
- **Two providers:** Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login.
|
||||
- **Auto-replies:** Static templates or external commands (Claude-aware), with per-sender or global sessions and `/new` resets.
|
||||
- Claude setup guide: see `docs/claude-config.md` for the exact Claude CLI configuration we support.
|
||||
- **Webhook in one go:** `warelay webhook --ingress tailscale` enables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL.
|
||||
- **Polling fallback:** `relay` polls Twilio when webhooks aren’t available; works headless.
|
||||
- **Status + delivery tracking:** `status` shows recent inbound/outbound; `send` can wait for final Twilio status.
|
||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
|
||||
## Command Cheat Sheet
|
||||
| Command | What it does | Core flags |
|
||||
| --- | --- | --- |
|
||||
| `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to <e164>` `--message <text>` `--wait <sec>` `--poll <sec>` `--provider twilio\|web` `--json` `--dry-run` `--verbose` |
|
||||
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
||||
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
|
||||
### Sending images
|
||||
- Twilio: `warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media` (needs `warelay webhook --ingress tailscale` or `--serve-media` to auto-host via Funnel; max 5 MB).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed).
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present).
|
||||
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||
|
||||
## Providers
|
||||
- **Twilio (default):** needs `.env` creds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators.
|
||||
- **Web (`--provider web`):** uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in `~/.warelay/credentials/` (rerun `login` if logged out).
|
||||
- **Auto-select (`relay` only):** `--provider auto` uses Web when logged in, otherwise Twilio polling.
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdis …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdis.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
|
||||
## What Clawdis does
|
||||
|
||||
- **Personal assistant** — one user, one identity, one memory surface.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS. Signal support via `signal-cli` (see `docs/signal.md`). iMessage uses `imsg` (see `docs/imessage.md`).
|
||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
||||
- **Nix mode** — opt-in declarative config + read-only UI when `CLAWDIS_NIX_MODE=1`.
|
||||
|
||||
## How it works (short)
|
||||
|
||||
- **Gateway** is the single source of truth for sessions/providers.
|
||||
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
|
||||
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
|
||||
- **Agent runtime** is **Pi** in RPC mode.
|
||||
|
||||
## Quick start (from source)
|
||||
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
# Recommended: run the onboarding wizard
|
||||
pnpm clawdis onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
||||
pnpm clawdis login
|
||||
|
||||
# Start the gateway
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
|
||||
# Send a message
|
||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Architecture
|
||||
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
|
||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||
|
||||
## Companion apps
|
||||
|
||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||
|
||||
### macOS (Clawdis.app)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
|
||||
### iOS node (internal)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdis nodes …`.
|
||||
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment (.env)
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `TWILIO_ACCOUNT_SID` | Yes (Twilio provider) | Twilio Account SID |
|
||||
| `TWILIO_AUTH_TOKEN` | Yes* | Auth token (or use API key/secret) |
|
||||
| `TWILIO_API_KEY` | Yes* | API key if not using auth token |
|
||||
| `TWILIO_API_SECRET` | Yes* | API secret paired with `TWILIO_API_KEY` |
|
||||
| `TWILIO_WHATSAPP_FROM` | Yes (Twilio provider) | WhatsApp-enabled sender, e.g. `whatsapp:+19995550123` |
|
||||
| `TWILIO_SENDER_SID` | Optional | Overrides auto-discovery of the sender SID |
|
||||
|
||||
(*Provide either auth token OR api key/secret.)
|
||||
|
||||
### Auto-reply config (`~/.warelay/warelay.json`, JSON5)
|
||||
- Controls who is allowed to trigger replies (`allowFrom`), reply mode (`text` or `command`), templates, and session behavior.
|
||||
- Example (Claude command):
|
||||
Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
allowFrom: ["+12345550000"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||
claudeOutputFormat: "text",
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 }
|
||||
}
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude CLI setup (how we run it)
|
||||
1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key.
|
||||
2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output).
|
||||
3) (Optional) Add `bodyPrefix` to inject a system prompt and `session` settings to keep multi-turn context (`/new` resets by default).
|
||||
4) Run `pnpm warelay relay --provider auto` (or `--provider web|twilio`) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
|
||||
### WhatsApp
|
||||
|
||||
### Auto-reply parameter table (compact)
|
||||
| Key | Type & default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). |
|
||||
| `inbound.reply.mode` | `"text"` \| `"command"` (default: —) | Reply style. |
|
||||
| `inbound.reply.text` | `string` (default: —) | Used when `mode=text`; templating supported. |
|
||||
| `inbound.reply.command` | `string[]` (default: —) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. |
|
||||
| `inbound.reply.template` | `string` (default: —) | Injected as argv[1] (prompt prefix) before the body. |
|
||||
| `inbound.reply.bodyPrefix` | `string` (default: —) | Prepended to `Body` before templating (great for system prompts). |
|
||||
| `inbound.reply.timeoutSeconds` | `number` (default: `600`) | Command timeout. |
|
||||
| `inbound.reply.claudeOutputFormat` | `"text"`\|`"json"`\|`"stream-json"` (default: —) | When command starts with `claude`, auto-adds `--output-format` + `-p/--print` and trims reply text. |
|
||||
| `inbound.reply.session.scope` | `"per-sender"`\|`"global"` (default: `per-sender`) | Session bucket for conversation memory. |
|
||||
| `inbound.reply.session.resetTriggers` | `string[]` (default: `["/new"]`) | Exact match or prefix (`/new hi`) resets session. |
|
||||
| `inbound.reply.session.idleMinutes` | `number` (default: `60`) | Session expires after idle period. |
|
||||
| `inbound.reply.session.store` | `string` (default: `~/.warelay/sessions.json`) | Custom session store path. |
|
||||
| `inbound.reply.session.sessionArgNew` | `string[]` (default: `["--session-id","{{SessionId}}"]`) | Args injected for a new session run. |
|
||||
| `inbound.reply.session.sessionArgResume` | `string[]` (default: `["--resume","{{SessionId}}"]`) | Args for resumed sessions. |
|
||||
| `inbound.reply.session.sessionArgBeforeBody` | `boolean` (default: `true`) | Place session args before final body arg. |
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
|
||||
Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled.
|
||||
### Telegram
|
||||
|
||||
## Webhook & Tailscale Flow
|
||||
- `warelay webhook --ingress none` starts the local Express server on your chosen port/path; add `--reply "Got it"` for a static reply when no config file is present.
|
||||
- `warelay webhook --ingress tailscale` enables Tailscale Funnel, prints the public URL (`https://<tailnet-host><path>`), starts the webhook, discovers the WhatsApp sender SID, and updates Twilio callbacks to the Funnel URL.
|
||||
- If Funnel is not allowed on your tailnet, the CLI exits with guidance; you can still use `relay --provider twilio` to poll without webhooks.
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
## Troubleshooting Tips
|
||||
- Send/receive issues: run `pnpm warelay status --limit 20 --lookback 240 --json` to inspect recent traffic.
|
||||
- Auto-reply not firing: ensure sender is in `allowFrom` (or unset), and confirm `.env` + `warelay.json` are loaded (reload shell after edits).
|
||||
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
||||
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
||||
```json5
|
||||
{
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## FAQ & Safety (quick answers)
|
||||
- Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body.
|
||||
- Does this store my messages? Warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs print to stdout/stderr; redirect or rotate if needed.
|
||||
- Personal WhatsApp safety: Automation on personal accounts can be rate-limited or logged out by WhatsApp. Use `--provider web` sparingly, keep messages human-like, and re-run `login` if the session is dropped.
|
||||
- Limits to remember: WhatsApp text limit ~1600 chars; avoid rapid bursts—space sends by a few seconds; keep webhook replies under a couple seconds for good UX; command auto-replies time out after 600s by default.
|
||||
- Deploy / keep running: Use `tmux` or `screen` for ad-hoc (`tmux new -s warelay -- pnpm warelay relay --provider twilio`). For long-running hosts, wrap `pnpm warelay relay ...` or `pnpm warelay webhook --ingress tailscale ...` in a systemd service or macOS LaunchAgent; ensure environment variables are loaded in that context.
|
||||
- Rotating credentials: Update `.env` (Twilio keys), rerun your process; for Web provider, delete `~/.warelay/credentials/` and rerun `pnpm warelay login` to relink.
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- [`docs/wizard.md`](docs/wizard.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail setup --account you@gmail.com
|
||||
clawdis hooks gmail run
|
||||
```
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: swabble
|
||||
steps:
|
||||
- name: Checkout swabble
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: swabble
|
||||
|
||||
- name: Select Xcode 26.1 (prefer 26.1.1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# pick the newest installed 26.1.x, fallback to newest 26.x
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
fi
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
echo "No Xcode 26.x found on runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Selecting $CANDIDATE"
|
||||
sudo xcode-select -s "$CANDIDATE"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Show Swift version
|
||||
run: swift --version
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
brew update
|
||||
brew install swiftlint swiftformat
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
./scripts/format.sh
|
||||
git diff --exit-code
|
||||
|
||||
- name: Lint
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
- name: Test
|
||||
run: swift test --parallel
|
||||
@@ -0,0 +1,33 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# SwiftPM / Build
|
||||
/.build
|
||||
/.swiftpm
|
||||
/DerivedData
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Editors
|
||||
/.vscode
|
||||
.idea/
|
||||
|
||||
# Xcode artifacts
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
*.xcplayground
|
||||
playground.xcworkspace
|
||||
timeline.xctimeline
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
@@ -0,0 +1,8 @@
|
||||
--swiftversion 6.2
|
||||
--indent 4
|
||||
--maxwidth 120
|
||||
--wraparguments before-first
|
||||
--wrapcollections before-first
|
||||
--stripunusedargs closure-only
|
||||
--self remove
|
||||
--header ""
|
||||
@@ -0,0 +1,43 @@
|
||||
# SwiftLint for swabble
|
||||
included:
|
||||
- Sources
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.swiftpm"
|
||||
- "**/.build"
|
||||
- "**/DerivedData"
|
||||
- "**/.DS_Store"
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- explicit_init
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- vertical_parameter_alignment_on_call
|
||||
- vertical_whitespace_opening_braces
|
||||
- vertical_whitespace_closing_braces
|
||||
|
||||
disabled_rules:
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- indentation_width
|
||||
- identifier_name
|
||||
- explicit_self
|
||||
- file_header
|
||||
- todo
|
||||
|
||||
line_length:
|
||||
warning: 140
|
||||
error: 180
|
||||
|
||||
reporter: "xcode"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — 2025-12-23
|
||||
|
||||
### Highlights
|
||||
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
|
||||
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
|
||||
|
||||
### Changes
|
||||
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
|
||||
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||
"version" : "600.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-testing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-testing",
|
||||
"state" : {
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.target(
|
||||
name: "SwabbleKit",
|
||||
path: "Sources/SwabbleKit",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
"SwabbleKit",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "SwabbleKitTests",
|
||||
dependencies: [
|
||||
"SwabbleKit",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
]),
|
||||
],
|
||||
swiftLanguageModes: [.v6])
|
||||
@@ -0,0 +1,111 @@
|
||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||
|
||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
||||
|
||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
||||
- **Services**: launchd helper stubs for start/stop/install.
|
||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
||||
|
||||
## Quick start
|
||||
```bash
|
||||
# Install deps
|
||||
brew install swiftformat swiftlint
|
||||
|
||||
# Build
|
||||
swift build
|
||||
|
||||
# Write default config (~/.config/swabble/config.json)
|
||||
swift run swabble setup
|
||||
|
||||
# Run foreground daemon
|
||||
swift run swabble serve
|
||||
|
||||
# Test your hook
|
||||
swift run swabble test-hook "hello world"
|
||||
|
||||
# Transcribe a file to SRT
|
||||
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
||||
```
|
||||
|
||||
## Use as a library
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [
|
||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
||||
]),
|
||||
]
|
||||
```
|
||||
|
||||
## CLI
|
||||
- `serve` — foreground loop (mic → wake → hook)
|
||||
- `transcribe <file>` — offline transcription (txt|srt)
|
||||
- `test-hook "text"` — invoke configured hook
|
||||
- `mic list|set <index>` — enumerate/select input device
|
||||
- `setup` — write default config JSON
|
||||
- `doctor` — check Speech auth & device availability
|
||||
- `health` — prints `ok`
|
||||
- `tail-log` — last 10 transcripts
|
||||
- `status` — show wake state + recent transcripts
|
||||
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
|
||||
- `start|stop|restart` — placeholders until full launchd wiring
|
||||
|
||||
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
|
||||
|
||||
## Config
|
||||
`~/.config/swabble/config.json` (auto-created by `setup`):
|
||||
```json
|
||||
{
|
||||
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
|
||||
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
|
||||
"hook": {
|
||||
"command": "",
|
||||
"args": [],
|
||||
"prefix": "Voice swabble from ${hostname}: ",
|
||||
"cooldownSeconds": 1,
|
||||
"minCharacters": 24,
|
||||
"timeoutSeconds": 5,
|
||||
"env": {}
|
||||
},
|
||||
"logging": {"level": "info", "format": "text"},
|
||||
"transcripts": {"enabled": true, "maxEntries": 50},
|
||||
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
|
||||
}
|
||||
```
|
||||
|
||||
- Config path override: `--config /path/to/config.json` on relevant commands.
|
||||
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
|
||||
|
||||
## Hook protocol
|
||||
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
|
||||
```
|
||||
<command> <args...> "<prefix><text>"
|
||||
```
|
||||
Environment variables:
|
||||
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
|
||||
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
|
||||
- plus any `hook.env` key/values
|
||||
|
||||
## Speech pipeline
|
||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
||||
- 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)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
- launchd control (load/bootout, PID + status socket)
|
||||
- JSON logging + PII redaction toggle
|
||||
- Stronger wake-word detection and control socket status/health
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
public struct SwabbleConfig: Codable, Sendable {
|
||||
public struct Audio: Codable, Sendable {
|
||||
public var deviceName: String = ""
|
||||
public var deviceIndex: Int = -1
|
||||
public var sampleRate: Double = 16000
|
||||
public var channels: Int = 1
|
||||
}
|
||||
|
||||
public struct Wake: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var word: String = "clawd"
|
||||
public var aliases: [String] = ["claude"]
|
||||
}
|
||||
|
||||
public struct Hook: Codable, Sendable {
|
||||
public var command: String = ""
|
||||
public var args: [String] = []
|
||||
public var prefix: String = "Voice swabble from ${hostname}: "
|
||||
public var cooldownSeconds: Double = 1
|
||||
public var minCharacters: Int = 24
|
||||
public var timeoutSeconds: Double = 5
|
||||
public var env: [String: String] = [:]
|
||||
}
|
||||
|
||||
public struct Logging: Codable, Sendable {
|
||||
public var level: String = "info"
|
||||
public var format: String = "text" // text|json placeholder
|
||||
}
|
||||
|
||||
public struct Transcripts: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var maxEntries: Int = 50
|
||||
}
|
||||
|
||||
public struct Speech: Codable, Sendable {
|
||||
public var localeIdentifier: String = Locale.current.identifier
|
||||
public var etiquetteReplacements: Bool = false
|
||||
}
|
||||
|
||||
public var audio = Audio()
|
||||
public var wake = Wake()
|
||||
public var hook = Hook()
|
||||
public var logging = Logging()
|
||||
public var transcripts = Transcripts()
|
||||
public var speech = Speech()
|
||||
|
||||
public static let defaultPath = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/swabble/config.json")
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum ConfigError: Error {
|
||||
case missingConfig
|
||||
}
|
||||
|
||||
public enum ConfigLoader {
|
||||
public static func load(at path: URL?) throws -> SwabbleConfig {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
throw ConfigError.missingConfig
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
|
||||
}
|
||||
|
||||
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(config)
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public struct HookJob: Sendable {
|
||||
public let text: String
|
||||
public let timestamp: Date
|
||||
|
||||
public init(text: String, timestamp: Date) {
|
||||
self.text = text
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public actor HookExecutor {
|
||||
private let config: SwabbleConfig
|
||||
private var lastRun: Date?
|
||||
private let hostname: String
|
||||
|
||||
public init(config: SwabbleConfig) {
|
||||
self.config = config
|
||||
hostname = Host.current().localizedName ?? "host"
|
||||
}
|
||||
|
||||
public func shouldRun() -> Bool {
|
||||
guard config.hook.cooldownSeconds > 0 else { return true }
|
||||
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public func run(job: HookJob) async throws {
|
||||
guard shouldRun() else { return }
|
||||
guard !config.hook.command.isEmpty else { throw NSError(
|
||||
domain: "Hook",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||
|
||||
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
|
||||
let payload = prefix + job.text
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: config.hook.command)
|
||||
process.arguments = config.hook.args + [payload]
|
||||
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["SWABBLE_TEXT"] = job.text
|
||||
env["SWABBLE_PREFIX"] = prefix
|
||||
for (k, v) in config.hook.env {
|
||||
env[k] = v
|
||||
}
|
||||
process.environment = env
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
try process.run()
|
||||
|
||||
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
process.waitUntilExit()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: timeoutNanos)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
lastRun = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@preconcurrency import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class BufferConverter {
|
||||
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
||||
enum ConverterError: Swift.Error {
|
||||
case failedToCreateConverter
|
||||
case failedToCreateConversionBuffer
|
||||
case conversionFailed(NSError?)
|
||||
}
|
||||
|
||||
private var converter: AVAudioConverter?
|
||||
|
||||
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
|
||||
let inputFormat = buffer.format
|
||||
if inputFormat == format {
|
||||
return buffer
|
||||
}
|
||||
if converter == nil || converter?.outputFormat != format {
|
||||
converter = AVAudioConverter(from: inputFormat, to: format)
|
||||
converter?.primeMethod = .none
|
||||
}
|
||||
guard let converter else { throw ConverterError.failedToCreateConverter }
|
||||
|
||||
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
||||
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
||||
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
||||
else {
|
||||
throw ConverterError.failedToCreateConversionBuffer
|
||||
}
|
||||
|
||||
var nsError: NSError?
|
||||
let consumed = Box(false)
|
||||
let inputBuffer = buffer
|
||||
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
|
||||
if consumed.value {
|
||||
statusPtr.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed.value = true
|
||||
statusPtr.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
if status == .error {
|
||||
throw ConverterError.conversionFailed(nsError)
|
||||
}
|
||||
return conversionBuffer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
case transcriberUnavailable
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
private var engine = AVAudioEngine()
|
||||
private var transcriber: SpeechTranscriber?
|
||||
private var analyzer: SpeechAnalyzer?
|
||||
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
|
||||
private var resultTask: Task<Void, Never>?
|
||||
private let converter = BufferConverter()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||
let auth = await requestAuthorizationIfNeeded()
|
||||
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
||||
|
||||
let transcriberModule = SpeechTranscriber(
|
||||
locale: Locale(identifier: localeIdentifier),
|
||||
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [.volatileResults],
|
||||
attributeOptions: [])
|
||||
transcriber = transcriberModule
|
||||
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
||||
else {
|
||||
throw SpeechPipelineError.analyzerFormatUnavailable
|
||||
}
|
||||
|
||||
analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||||
inputContinuation = continuation
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
let boxed = UnsafeBuffer(buffer: buffer)
|
||||
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
try await analyzer?.start(inputSequence: stream)
|
||||
|
||||
guard let transcriberForStream = transcriber else {
|
||||
throw SpeechPipelineError.transcriberUnavailable
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
self.resultTask = Task {
|
||||
do {
|
||||
for try await result in transcriberForStream.results {
|
||||
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
||||
continuation.yield(seg)
|
||||
}
|
||||
} catch {
|
||||
// swallow errors and finish
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
resultTask?.cancel()
|
||||
inputContinuation?.finish()
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||
}
|
||||
|
||||
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
||||
do {
|
||||
let converted = try converter.convert(buffer, to: targetFormat)
|
||||
let input = AnalyzerInput(buffer: converted)
|
||||
inputContinuation?.yield(input)
|
||||
} catch {
|
||||
// drop on conversion failure
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
|
||||
let current = SFSpeechRecognizer.authorizationStatus()
|
||||
guard current == .notDetermined else { return current }
|
||||
return await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
extension AttributedString {
|
||||
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
let string = String(characters)
|
||||
tokenizer.string = string
|
||||
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
|
||||
(
|
||||
$0,
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!)
|
||||
}
|
||||
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
||||
let sentence = self[sentenceRange]
|
||||
guard let maxLength, sentence.characters.count > maxLength else {
|
||||
return [sentenceRange]
|
||||
}
|
||||
|
||||
let wordTokenizer = NLTokenizer(unit: .word)
|
||||
wordTokenizer.string = string
|
||||
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!
|
||||
}
|
||||
guard !wordRanges.isEmpty else { return [sentenceRange] }
|
||||
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
|
||||
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
|
||||
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
return ranges.compactMap { range in
|
||||
let audioTimeRanges = self[range].runs.filter {
|
||||
!String(self[$0.range].characters)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}.compactMap(\.audioTimeRange)
|
||||
guard !audioTimeRanges.isEmpty else { return nil }
|
||||
let start = audioTimeRanges.first!.start
|
||||
let end = audioTimeRanges.last!.end
|
||||
var attributes = AttributeContainer()
|
||||
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
||||
start: start,
|
||||
end: end)
|
||||
return AttributedString(self[range].characters, attributes: attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
|
||||
case trace, debug, info, warn, error
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .trace: 0
|
||||
case .debug: 1
|
||||
case .info: 2
|
||||
case .warn: 3
|
||||
case .error: 4
|
||||
}
|
||||
}
|
||||
|
||||
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
|
||||
}
|
||||
|
||||
public struct Logger: Sendable {
|
||||
public let level: LogLevel
|
||||
|
||||
public init(level: LogLevel) { self.level = level }
|
||||
|
||||
public func log(_ level: LogLevel, _ message: String) {
|
||||
guard level >= self.level else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
|
||||
}
|
||||
|
||||
public func trace(_ msg: String) { log(.trace, msg) }
|
||||
public func debug(_ msg: String) { log(.debug, msg) }
|
||||
public func info(_ msg: String) { log(.info, msg) }
|
||||
public func warn(_ msg: String) { log(.warn, msg) }
|
||||
public func error(_ msg: String) { log(.error, msg) }
|
||||
}
|
||||
|
||||
extension LogLevel {
|
||||
public init?(configValue: String) {
|
||||
self.init(rawValue: configValue.lowercased())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
public enum OutputFormat: String {
|
||||
case txt
|
||||
case srt
|
||||
|
||||
public var needsAudioTimeRange: Bool {
|
||||
switch self {
|
||||
case .srt: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||
switch self {
|
||||
case .txt:
|
||||
return String(transcript.characters)
|
||||
case .srt:
|
||||
func format(_ timeInterval: TimeInterval) -> String {
|
||||
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
|
||||
let s = Int(timeInterval) % 60
|
||||
let m = (Int(timeInterval) / 60) % 60
|
||||
let h = Int(timeInterval) / 60 / 60
|
||||
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
||||
}
|
||||
|
||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
||||
CMTimeRange,
|
||||
String)? in
|
||||
guard let timeRange = sentence.audioTimeRange else { return nil }
|
||||
return (timeRange, String(sentence.characters))
|
||||
}.enumerated().map { index, run in
|
||||
let (timeRange, text) = run
|
||||
return """
|
||||
|
||||
\(index + 1)
|
||||
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
|
||||
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
"""
|
||||
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public actor TranscriptsStore {
|
||||
public static let shared = TranscriptsStore()
|
||||
|
||||
private var entries: [String] = []
|
||||
private let limit = 100
|
||||
private let fileURL: URL
|
||||
|
||||
public init() {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
||||
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)
|
||||
{
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func append(text: String) {
|
||||
entries.append(text)
|
||||
if entries.count > limit {
|
||||
entries.removeFirst(entries.count - limit)
|
||||
}
|
||||
let body = entries.joined(separator: "\n")
|
||||
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func latest() -> [String] { entries }
|
||||
}
|
||||
|
||||
extension String {
|
||||
private func appendLine(to url: URL) throws {
|
||||
let data = (self + "\n").data(using: .utf8) ?? Data()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: data)
|
||||
try handle.close()
|
||||
} else {
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
public struct WakeWordSegment: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let start: TimeInterval
|
||||
public let duration: TimeInterval
|
||||
public let range: Range<String.Index>?
|
||||
|
||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
||||
self.text = text
|
||||
self.start = start
|
||||
self.duration = duration
|
||||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { self.start + self.duration }
|
||||
}
|
||||
|
||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public var triggers: [String]
|
||||
public var minPostTriggerGap: TimeInterval
|
||||
public var minCommandLength: Int
|
||||
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeWordGateMatch: Sendable, Equatable {
|
||||
public let triggerEndTime: TimeInterval
|
||||
public let postGap: TimeInterval
|
||||
public let command: String
|
||||
|
||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
||||
self.triggerEndTime = triggerEndTime
|
||||
self.postGap = postGap
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public enum WakeWordGate {
|
||||
private struct Token {
|
||||
let normalized: String
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
let range: Range<String.Index>?
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct TriggerTokens {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
guard count > 0, tokens.count > count else { continue }
|
||||
for i in 0...(tokens.count - count - 1) {
|
||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
let nextToken = tokens[i + count]
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
guard let best else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
}
|
||||
|
||||
public static func commandText(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggerEndTime: TimeInterval)
|
||||
-> String {
|
||||
let threshold = triggerEndTime + 0.001
|
||||
for segment in segments where segment.start >= threshold {
|
||||
if normalizeToken(segment.text).isEmpty { continue }
|
||||
if let range = segment.range {
|
||||
let slice = transcript[range.lowerBound...]
|
||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let text = segments
|
||||
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let normalized = text.lowercased()
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if normalized.contains(token) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||
var out = text
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||
var output: [TriggerTokens] = []
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(tokens: tokens))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = self.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return Token(
|
||||
normalized: normalized,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
range: segment.range,
|
||||
text: segment.text)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
||||
.union(.punctuationCharacters)
|
||||
}
|
||||
|
||||
#if canImport(Speech)
|
||||
import Speech
|
||||
|
||||
public enum WakeWordSpeechSegments {
|
||||
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
|
||||
transcription.segments.map { segment in
|
||||
let range = Range(segment.substringRange, in: transcript)
|
||||
return WakeWordSegment(
|
||||
text: segment.substring,
|
||||
start: segment.timestamp,
|
||||
duration: segment.duration,
|
||||
range: range)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,71 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
enum CLIRegistry {
|
||||
static var descriptors: [CommandDescriptor] {
|
||||
let serveDesc = descriptor(for: ServeCommand.self)
|
||||
let transcribeDesc = descriptor(for: TranscribeCommand.self)
|
||||
let testHookDesc = descriptor(for: TestHookCommand.self)
|
||||
let micList = descriptor(for: MicList.self)
|
||||
let micSet = descriptor(for: MicSet.self)
|
||||
let micRoot = CommandDescriptor(
|
||||
name: "mic",
|
||||
abstract: "Microphone management",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [micList, micSet])
|
||||
let serviceRoot = CommandDescriptor(
|
||||
name: "service",
|
||||
abstract: "launchd helper",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
let healthDesc = descriptor(for: HealthCommand.self)
|
||||
let tailLogDesc = descriptor(for: TailLogCommand.self)
|
||||
let startDesc = descriptor(for: StartCommand.self)
|
||||
let stopDesc = descriptor(for: StopCommand.self)
|
||||
let restartDesc = descriptor(for: RestartCommand.self)
|
||||
let statusDesc = descriptor(for: StatusCommand.self)
|
||||
|
||||
let rootSignature = CommandSignature().withStandardRuntimeFlags()
|
||||
let root = CommandDescriptor(
|
||||
name: "swabble",
|
||||
abstract: "Speech hook daemon",
|
||||
discussion: "Local wake-word → SpeechTranscriber → hook",
|
||||
signature: rootSignature,
|
||||
subcommands: [
|
||||
serveDesc,
|
||||
transcribeDesc,
|
||||
testHookDesc,
|
||||
micRoot,
|
||||
serviceRoot,
|
||||
doctorDesc,
|
||||
setupDesc,
|
||||
healthDesc,
|
||||
tailLogDesc,
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
|
||||
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
|
||||
return CommandDescriptor(
|
||||
name: type.commandDescription.commandName ?? "",
|
||||
abstract: type.commandDescription.abstract,
|
||||
discussion: type.commandDescription.discussion,
|
||||
signature: sig,
|
||||
subcommands: [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct DoctorCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let auth = await SFSpeechRecognizer.authorizationStatus()
|
||||
print("Speech auth: \(auth)")
|
||||
do {
|
||||
_ = try ConfigLoader.load(at: configURL)
|
||||
print("Config: OK")
|
||||
} catch {
|
||||
print("Config missing or invalid; run setup")
|
||||
}
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
print("Mics found: \(session.devices.count)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct HealthCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "health", abstract: "Health probe")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("ok")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct MicCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "mic",
|
||||
abstract: "Microphone management",
|
||||
subcommands: [MicList.self, MicSet.self])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicList: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "list", abstract: "List input devices")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let devices = session.devices
|
||||
if devices.isEmpty { print("no audio inputs found"); return }
|
||||
for (idx, device) in devices.enumerated() {
|
||||
print("[\(idx)] \(device.localizedName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicSet: ParsableCommand {
|
||||
@Argument(help: "Device index from list") var index: Int = 0
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "set", abstract: "Set default input device index")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg = try ConfigLoader.load(at: configURL)
|
||||
cfg.audio.deviceIndex = index
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("saved device index \(index)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
import SwabbleKit
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
struct ServeCommand: ParsableCommand {
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "serve",
|
||||
abstract: "Run swabble in the foreground")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if parsed.flags.contains("noWake") { noWake = true }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg: SwabbleConfig
|
||||
do {
|
||||
cfg = try ConfigLoader.load(at: configURL)
|
||||
} catch {
|
||||
cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
}
|
||||
if noWake {
|
||||
cfg.wake.enabled = false
|
||||
}
|
||||
|
||||
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
|
||||
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
||||
let pipeline = SpeechPipeline()
|
||||
do {
|
||||
let stream = try await pipeline.start(
|
||||
localeIdentifier: cfg.speech.localeIdentifier,
|
||||
etiquette: cfg.speech.etiquetteReplacements)
|
||||
for await seg in stream {
|
||||
if cfg.wake.enabled {
|
||||
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
||||
}
|
||||
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
|
||||
let job = HookJob(text: stripped, timestamp: Date())
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: job)
|
||||
if cfg.transcripts.enabled {
|
||||
await TranscriptsStore.shared.append(text: stripped)
|
||||
}
|
||||
if seg.isFinal {
|
||||
logger.info("final: \(stripped)")
|
||||
} else {
|
||||
logger.debug("partial: \(stripped)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("serve error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? {
|
||||
configPath.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct ServiceRootCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "service",
|
||||
abstract: "Manage launchd agent",
|
||||
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
|
||||
}
|
||||
}
|
||||
|
||||
private enum LaunchdHelper {
|
||||
static let label = "com.swabble.agent"
|
||||
|
||||
static var plistURL: URL {
|
||||
FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||
}
|
||||
|
||||
static func writePlist(executable: String) throws {
|
||||
let plist: [String: Any] = [
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
}
|
||||
|
||||
static func removePlist() throws {
|
||||
try? FileManager.default.removeItem(at: plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceInstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "install", abstract: "Install user launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
|
||||
try LaunchdHelper.writePlist(executable: exe)
|
||||
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceUninstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
try LaunchdHelper.removePlist()
|
||||
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceStatus: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show launch agent status")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
|
||||
print("plist present at \(LaunchdHelper.plistURL.path)")
|
||||
} else {
|
||||
print("launchd plist not installed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct SetupCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "setup", abstract: "Write default config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct StartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("start: launchd helper not implemented; run 'swabble serve' instead")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct StopCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("stop: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RestartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("restart: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct StatusCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show daemon state")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try? ConfigLoader.load(at: configURL)
|
||||
let wake = cfg?.wake.word ?? "clawd"
|
||||
let wakeEnabled = cfg?.wake.enabled ?? false
|
||||
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
||||
print("wake: \(wakeEnabled ? wake : "disabled")")
|
||||
if latest.isEmpty {
|
||||
print("transcripts: (none yet)")
|
||||
} else {
|
||||
print("last transcripts:")
|
||||
latest.forEach { print("- \($0)") }
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TailLogCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let latest = await TranscriptsStore.shared.latest()
|
||||
for line in latest.suffix(10) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TestHookCommand: ParsableCommand {
|
||||
@Argument(help: "Text to send to hook") var text: String
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { text = positional }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try ConfigLoader.load(at: configURL)
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: HookJob(text: text, timestamp: Date()))
|
||||
print("hook invoked")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TranscribeCommand: ParsableCommand {
|
||||
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
||||
.identifier
|
||||
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
||||
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
||||
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
||||
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "transcribe",
|
||||
abstract: "Transcribe a media file locally")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { inputFile = positional }
|
||||
if let loc = parsed.options["locale"]?.last { locale = loc }
|
||||
if parsed.flags.contains("censor") { censor = true }
|
||||
if let out = parsed.options["output"]?.last { outputFile = out }
|
||||
if let fmt = parsed.options["format"]?.last { format = fmt }
|
||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let fileURL = URL(fileURLWithPath: inputFile)
|
||||
let audioFile = try AVAudioFile(forReading: fileURL)
|
||||
|
||||
let outputFormat = OutputFormat(rawValue: format) ?? .txt
|
||||
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: Locale(identifier: locale),
|
||||
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [],
|
||||
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
|
||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
|
||||
|
||||
var transcript: AttributedString = ""
|
||||
for try await result in transcriber.results {
|
||||
transcript += result.text
|
||||
}
|
||||
|
||||
let output = outputFormat.text(for: transcript, maxLength: maxLength)
|
||||
if let path = outputFile {
|
||||
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
||||
} else {
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func runCLI() async -> Int32 {
|
||||
do {
|
||||
let descriptors = CLIRegistry.descriptors
|
||||
let program = Program(descriptors: descriptors)
|
||||
let invocation = try program.resolve(argv: CommandLine.arguments)
|
||||
try await dispatch(invocation: invocation)
|
||||
return 0
|
||||
} catch {
|
||||
fputs("error: \(error)\n", stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatch(invocation: CommandInvocation) async throws {
|
||||
let parsed = invocation.parsedValues
|
||||
let path = invocation.path
|
||||
guard let first = path.first else { throw CommanderProgramError.missingCommand }
|
||||
|
||||
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)
|
||||
}
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
} else {
|
||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@Suite struct WakeWordGateTests {
|
||||
@Test func matchRequiresGapAfterTrigger() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
||||
}
|
||||
|
||||
@Test func matchAllowsGapAndExtractsCommand() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do thing")
|
||||
}
|
||||
|
||||
@Test func matchHandlesMultiWordTriggers() {
|
||||
let transcript = "hey clawd do it"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.8, 0.1),
|
||||
("it", 1.0, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSegments(
|
||||
transcript: String,
|
||||
words: [(String, TimeInterval, TimeInterval)])
|
||||
-> [WakeWordSegment] {
|
||||
var searchStart = transcript.startIndex
|
||||
var output: [WakeWordSegment] = []
|
||||
for (word, start, duration) in words {
|
||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
||||
if let range { searchStart = range.upperBound }
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Swabble
|
||||
|
||||
@Test
|
||||
func configRoundTrip() throws {
|
||||
var cfg = SwabbleConfig()
|
||||
cfg.wake.word = "robot"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
try ConfigLoader.save(cfg, at: url)
|
||||
let loaded = try ConfigLoader.load(at: url)
|
||||
#expect(loaded.wake.word == "robot")
|
||||
#expect(loaded.hook.prefix.contains("Voice swabble"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func configMissingThrows() {
|
||||
#expect(throws: ConfigError.missingConfig) {
|
||||
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
||||
|
||||
## Requirements
|
||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||
- Local only; no network calls during transcription.
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
- Foreground `serve`; later launchd helper for start/stop/restart.
|
||||
- File transcription command emitting txt or srt.
|
||||
- Basic status/health surfaces and mic selection stubs.
|
||||
|
||||
## Architecture
|
||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||
- **Logging**: simple structured logger to stderr; respects log level.
|
||||
|
||||
## Out of scope (initial cut)
|
||||
- Model management (Speech handles assets).
|
||||
- Launchd helper (planned follow-up).
|
||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
||||
|
||||
## Open decisions
|
||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/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
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/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
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftlint --config "$CONFIG"
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
@@ -0,0 +1,51 @@
|
||||
## Clawdis Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
|
||||
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
|
||||
|
||||
## Open in Android Studio
|
||||
- Open the folder `apps/android`.
|
||||
|
||||
## Build / Run
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:installDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Connect / Pair
|
||||
|
||||
1) Start the gateway (on your “master” machine):
|
||||
```bash
|
||||
pnpm clawdis 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).
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
```bash
|
||||
clawdis nodes pending
|
||||
clawdis nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/android/connect.md`.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Discovery:
|
||||
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
|
||||
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
|
||||
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
|
||||
- Camera:
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
@@ -0,0 +1,104 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.clawdis.android"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.clawdis.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.3")
|
||||
|
||||
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")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.ClawdisNode">
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fallback": {
|
||||
"emoji": "🧩",
|
||||
"detailKeys": [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Bash",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
"detailKeys": ["sessionId"]
|
||||
},
|
||||
"read": {
|
||||
"emoji": "📖",
|
||||
"title": "Read",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"write": {
|
||||
"emoji": "✍️",
|
||||
"title": "Write",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"edit": {
|
||||
"emoji": "📝",
|
||||
"title": "Edit",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"attach": {
|
||||
"emoji": "📎",
|
||||
"title": "Attach",
|
||||
"detailKeys": ["path", "url", "fileName"]
|
||||
},
|
||||
"browser": {
|
||||
"emoji": "🌐",
|
||||
"title": "Browser",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"start": { "label": "start" },
|
||||
"stop": { "label": "stop" },
|
||||
"tabs": { "label": "tabs" },
|
||||
"open": { "label": "open", "detailKeys": ["targetUrl"] },
|
||||
"focus": { "label": "focus", "detailKeys": ["targetId"] },
|
||||
"close": { "label": "close", "detailKeys": ["targetId"] },
|
||||
"snapshot": {
|
||||
"label": "snapshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
|
||||
},
|
||||
"screenshot": {
|
||||
"label": "screenshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
|
||||
},
|
||||
"navigate": {
|
||||
"label": "navigate",
|
||||
"detailKeys": ["targetUrl", "targetId"]
|
||||
},
|
||||
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
|
||||
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
|
||||
"upload": {
|
||||
"label": "upload",
|
||||
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
|
||||
},
|
||||
"dialog": {
|
||||
"label": "dialog",
|
||||
"detailKeys": ["accept", "promptText", "targetId"]
|
||||
},
|
||||
"act": {
|
||||
"label": "act",
|
||||
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"canvas": {
|
||||
"emoji": "🖼️",
|
||||
"title": "Canvas",
|
||||
"actions": {
|
||||
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
|
||||
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
|
||||
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
|
||||
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
|
||||
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
|
||||
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
|
||||
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"emoji": "📱",
|
||||
"title": "Nodes",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
|
||||
"pending": { "label": "pending" },
|
||||
"approve": { "label": "approve", "detailKeys": ["requestId"] },
|
||||
"reject": { "label": "reject", "detailKeys": ["requestId"] },
|
||||
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
|
||||
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
|
||||
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
|
||||
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"emoji": "⏰",
|
||||
"title": "Cron",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"list": { "label": "list" },
|
||||
"add": {
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
"actions": {
|
||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
||||
}
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
"actions": {
|
||||
"start": { "label": "start" },
|
||||
"wait": { "label": "wait" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"emoji": "💬",
|
||||
"title": "Discord",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
|
||||
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
|
||||
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
|
||||
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
||||
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
|
||||
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
|
||||
"eventList": { "label": "events", "detailKeys": ["guildId"] },
|
||||
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.clawdis.android
|
||||
|
||||
enum class CameraHudKind {
|
||||
Photo,
|
||||
Recording,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class CameraHudState(
|
||||
val token: Long,
|
||||
val kind: CameraHudKind,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
object DeviceNames {
|
||||
fun bestDefaultNodeName(context: Context): String {
|
||||
val deviceName =
|
||||
runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
}
|
||||
.getOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
|
||||
if (deviceName.isNotEmpty()) return deviceName
|
||||
|
||||
val model =
|
||||
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
|
||||
return model.ifEmpty { "Android Node" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.clawdis.android.ui.RootScreen
|
||||
import com.clawdis.android.ui.ClawdisTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private lateinit var screenCaptureRequester: ScreenCaptureRequester
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
||||
applyImmersiveMode()
|
||||
requestDiscoveryPermissionsIfNeeded()
|
||||
requestNotificationPermissionIfNeeded()
|
||||
NodeForegroundService.start(this)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.preventSleep.collect { enabled ->
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
ClawdisTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
|
||||
private fun requestDiscoveryPermissionsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
|
||||
}
|
||||
} else {
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < 33) return
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.clawdis.android.bridge.BridgeEndpoint
|
||||
import com.clawdis.android.chat.OutgoingAttachment
|
||||
import com.clawdis.android.node.CameraCaptureManager
|
||||
import com.clawdis.android.node.CanvasController
|
||||
import com.clawdis.android.node.ScreenRecordManager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
val chatMessages = runtime.chatMessages
|
||||
val chatError: StateFlow<String?> = runtime.chatError
|
||||
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||
val chatSessions = runtime.chatSessions
|
||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
runtime.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
runtime.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
runtime.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
runtime.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
runtime.resetWakeWordsDefaults()
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
runtime.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(enabled: Boolean) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
runtime.refreshChat()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
runtime.refreshChatSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
runtime.setChatThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
runtime.switchChatSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
private var lastRequiresMic = false
|
||||
private var didStartForeground = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||
|
||||
val runtime = (application as NodeApp).runtime
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.voiceWakeMode,
|
||||
runtime.voiceWakeIsListening,
|
||||
) { status, server, connected, voiceMode, voiceListening ->
|
||||
Quint(status, server, connected, voiceMode, voiceListening)
|
||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||
val voiceSuffix =
|
||||
if (voiceMode == VoiceWakeMode.Always) {
|
||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
|
||||
|
||||
val requiresMic =
|
||||
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
|
||||
startForegroundWithTypes(
|
||||
notification = buildNotification(title = title, text = text),
|
||||
requiresMic = requiresMic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).runtime.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
notificationJob?.cancel()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun ensureChannel() {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Clawdis node connection status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(notification: Notification) {
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
mgr.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||
updateNotification(notification)
|
||||
return
|
||||
}
|
||||
|
||||
lastRequiresMic = requiresMic
|
||||
val types =
|
||||
if (requiresMic) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, types)
|
||||
didStartForeground = true
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "connection"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private const val ACTION_STOP = "com.clawdis.android.action.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)
|
||||
@@ -0,0 +1,997 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdis.android.chat.ChatController
|
||||
import com.clawdis.android.chat.ChatMessage
|
||||
import com.clawdis.android.chat.ChatPendingToolCall
|
||||
import com.clawdis.android.chat.ChatSessionEntry
|
||||
import com.clawdis.android.chat.OutgoingAttachment
|
||||
import com.clawdis.android.bridge.BridgeDiscovery
|
||||
import com.clawdis.android.bridge.BridgeEndpoint
|
||||
import com.clawdis.android.bridge.BridgePairingClient
|
||||
import com.clawdis.android.bridge.BridgeSession
|
||||
import com.clawdis.android.node.CameraCaptureManager
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.node.CanvasController
|
||||
import com.clawdis.android.node.ScreenRecordManager
|
||||
import com.clawdis.android.protocol.ClawdisCapability
|
||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
||||
import com.clawdis.android.voice.TalkModeManager
|
||||
import com.clawdis.android.voice.VoiceWakeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
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 java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val screenRecorder = ScreenRecordManager(appContext)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
|
||||
private val voiceWake: VoiceWakeManager by lazy {
|
||||
VoiceWakeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
onCommand = { command ->
|
||||
session.sendEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val voiceWakeIsListening: StateFlow<Boolean>
|
||||
get() = voiceWake.isListening
|
||||
|
||||
val voiceWakeStatusText: StateFlow<String>
|
||||
get() = voiceWake.statusText
|
||||
|
||||
val talkStatusText: StateFlow<String>
|
||||
get() = talkMode.statusText
|
||||
|
||||
val talkIsListening: StateFlow<Boolean>
|
||||
get() = talkMode.isListening
|
||||
|
||||
val talkIsSpeaking: StateFlow<Boolean>
|
||||
get() = talkMode.isSpeaking
|
||||
|
||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
|
||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||
|
||||
private val _screenRecordActive = MutableStateFlow(false)
|
||||
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||
|
||||
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
|
||||
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
|
||||
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { name, remote ->
|
||||
_statusText.value = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onEvent = { event, payloadJson ->
|
||||
handleBridgeEvent(event, payloadJson)
|
||||
},
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
|
||||
}
|
||||
|
||||
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 maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
private var suppressWakeWordsSync = false
|
||||
private var wakeWordsSyncJob: Job? = null
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||
val chatError: StateFlow<String?> = chat.errorText
|
||||
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
voiceWakeMode,
|
||||
isForeground,
|
||||
externalAudioCaptureActive,
|
||||
wakeWords,
|
||||
) { mode, foreground, externalAudio, words ->
|
||||
Quad(mode, foreground, externalAudio, words)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (mode, foreground, externalAudio, words) ->
|
||||
voiceWake.setTriggerWords(words)
|
||||
|
||||
val shouldListen =
|
||||
when (mode) {
|
||||
VoiceWakeMode.Off -> false
|
||||
VoiceWakeMode.Foreground -> foreground
|
||||
VoiceWakeMode.Always -> true
|
||||
} && !externalAudio
|
||||
|
||||
if (!shouldListen) {
|
||||
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
|
||||
return@collect
|
||||
}
|
||||
|
||||
if (!hasRecordAudioPermission()) {
|
||||
voiceWake.stop(statusText = "Microphone permission required")
|
||||
return@collect
|
||||
}
|
||||
|
||||
voiceWake.start()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
talkEnabled.collect { enabled ->
|
||||
talkMode.setEnabled(enabled)
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
bridges.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Persist the last discovered bridge (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))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
canvasDebugStatusEnabled,
|
||||
statusText,
|
||||
serverName,
|
||||
remoteAddress,
|
||||
) { debugEnabled, status, server, remote ->
|
||||
Quad(debugEnabled, status, server, remote)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (debugEnabled, status, server, remote) ->
|
||||
canvas.setDebugStatusEnabled(debugEnabled)
|
||||
if (!debugEnabled) return@collect
|
||||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
prefs.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
scheduleWakeWordsSyncIfNeeded()
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
setWakeWords(SecurePrefs.defaultWakeWords)
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.setTalkEnabled(value)
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
val invokeCommands =
|
||||
buildList {
|
||||
add(ClawdisCanvasCommand.Present.rawValue)
|
||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||
add(ClawdisScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled.value) {
|
||||
add(ClawdisCameraCommand.Snap.rawValue)
|
||||
add(ClawdisCameraCommand.Clip.rawValue)
|
||||
}
|
||||
}
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
val caps = buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
}
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = caps,
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
}
|
||||
|
||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||
_statusText.value = "Failed: pairing required"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val authToken = requireNotNull(resolved.token).trim()
|
||||
prefs.saveBridgeToken(authToken)
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = authToken,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps =
|
||||
buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
},
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
return
|
||||
}
|
||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||
} catch (_: Throwable) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
}
|
||||
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||
|
||||
val surfaceId =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
val sourceComponentId =
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val message =
|
||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
sourceComponentId = sourceComponentId,
|
||||
host = displayName.value,
|
||||
instanceId = instanceId.value.lowercase(),
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = isConnected.value
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
session.sendEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(message))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "bridge not connected"
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.eval(
|
||||
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
error = error,
|
||||
),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
chat.refresh()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
chat.refreshSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
chat.setThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
chat.switchSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
chat.abort()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
if (event == "voicewake.changed") {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
talkMode.handleBridgeEvent(event, payloadJson)
|
||||
chat.handleBridgeEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
suppressWakeWordsSync = true
|
||||
prefs.setWakeWords(words)
|
||||
suppressWakeWordsSync = false
|
||||
}
|
||||
|
||||
private fun scheduleWakeWordsSyncIfNeeded() {
|
||||
if (suppressWakeWordsSync) return
|
||||
if (!_isConnected.value) return
|
||||
|
||||
val snapshot = prefs.wakeWords.value
|
||||
wakeWordsSyncJob?.cancel()
|
||||
wakeWordsSyncJob =
|
||||
scope.launch {
|
||||
delay(650)
|
||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||
val params = """{"triggers":[$jsonList]}"""
|
||||
try {
|
||||
session.request("voicewake.set", params)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.request("voicewake.get", "{}")
|
||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.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 rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
|
||||
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||
if (
|
||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
ClawdisCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.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(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(a2uiResetJS)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
decodeA2uiMessages(command, paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||
}
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.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(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdisCameraCommand.Snap.rawValue -> {
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camera.snap(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
ClawdisCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
|
||||
val res =
|
||||
try {
|
||||
camera.clip(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
ClawdisScreenCommand.Record.rawValue -> {
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
_screenRecordActive.value = true
|
||||
try {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
_screenRecordActive.value = false
|
||||
}
|
||||
}
|
||||
else ->
|
||||
BridgeSession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
}
|
||||
|
||||
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
|
||||
val token = cameraHudSeq.incrementAndGet()
|
||||
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
|
||||
|
||||
if (autoHideMs != null && autoHideMs > 0) {
|
||||
scope.launch {
|
||||
delay(autoHideMs)
|
||||
if (_cameraHud.value?.token == token) _cameraHud.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
// Preserve full string for callers/logging, but keep the returned message human-friendly.
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__clawdis__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexed { idx, line ->
|
||||
val el = json.parseToJsonElement(line)
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
.toList()
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
return JsonArray(out).toString()
|
||||
}
|
||||
|
||||
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
}
|
||||
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||
val matched = msg.keys.filter { allowed.contains(it) }
|
||||
if (matched.size != 1) {
|
||||
val found = msg.keys.sorted().joinToString(", ")
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
return globalThis.clawdisA2UI.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return globalThis.clawdisA2UI.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun String.toJsonString(): String {
|
||||
val escaped =
|
||||
this.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
return "\"$escaped\""
|
||||
}
|
||||
|
||||
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 parseHexColorArgb(raw: String?): Long? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
|
||||
if (hex.length != 6) return null
|
||||
val rgb = hex.toLongOrNull(16) ?: return null
|
||||
return 0xFF000000L or rgb
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(private val activity: ComponentActivity) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
p?.complete(result)
|
||||
}
|
||||
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
): Map<String, Boolean> =
|
||||
mutex.withLock {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) {
|
||||
launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.Default) {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
}
|
||||
|
||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSettingsDialog(permissions: List<String>) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||
}
|
||||
|
||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
|
||||
}
|
||||
|
||||
private fun permissionLabel(permission: String): String =
|
||||
when (permission) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.clawdis.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
||||
data class CaptureResult(val resultCode: Int, val data: Intent)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<CaptureResult?>? = null
|
||||
|
||||
private val launcher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
val data = result.data
|
||||
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
||||
p?.complete(CaptureResult(result.resultCode, data))
|
||||
} else {
|
||||
p?.complete(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
|
||||
mutex.withLock {
|
||||
val proceed = showRationaleDialog()
|
||||
if (!proceed) return null
|
||||
|
||||
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val intent = mgr.createScreenCaptureIntent()
|
||||
|
||||
val deferred = CompletableDeferred<CaptureResult?>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) { launcher.launch(intent) }
|
||||
|
||||
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Screen recording required")
|
||||
.setMessage("Clawdis needs to record the screen for this command.")
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.clawdis.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
class SecurePrefs(context: Context) {
|
||||
companion object {
|
||||
val defaultWakeWords: List<String> = listOf("clawd", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val masterKey =
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs =
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"clawdis.node.secure",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
|
||||
private val _displayName =
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadBridgeToken(): String? {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun saveBridgeToken(token: String) {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
val array = element as? JsonArray ?: return defaultWakeWords
|
||||
val decoded =
|
||||
array.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
WakeWords.sanitize(decoded, defaultWakeWords)
|
||||
} catch (_: Throwable) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.clawdis.android
|
||||
|
||||
enum class VoiceWakeMode(val rawValue: String) {
|
||||
Off("off"),
|
||||
Foreground("foreground"),
|
||||
Always("always"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): VoiceWakeMode {
|
||||
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.clawdis.android
|
||||
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
const val maxWordLength: Int = 64
|
||||
|
||||
fun parseCommaSeparated(input: String): List<String> {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.clawdis.android.bridge
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var i = 0
|
||||
while (i < input.length) {
|
||||
if (input[i] == '\\' && i + 3 < input.length) {
|
||||
val d0 = input[i + 1]
|
||||
val d1 = input[i + 2]
|
||||
val d2 = input[i + 3]
|
||||
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
||||
val value =
|
||||
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||
if (value in 0..255) {
|
||||
bytes.add(value.toByte())
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val codePoint = Character.codePointAt(input, i)
|
||||
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
|
||||
for (b in charBytes) {
|
||||
bytes.add(b)
|
||||
}
|
||||
i += Character.charCount(codePoint)
|
||||
}
|
||||
|
||||
return String(bytes.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package com.clawdis.android.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.xbill.DNS.AAAARecord
|
||||
import org.xbill.DNS.ARecord
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.Rcode
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.SimpleResolver
|
||||
import org.xbill.DNS.TextParseException
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class BridgeDiscovery(
|
||||
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 = "_clawdis-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdis.internal."
|
||||
private val logTag = "Clawdis/BridgeDiscovery"
|
||||
|
||||
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 _statusText = MutableStateFlow("Searching…")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private var unicastJob: Job? = null
|
||||
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||
|
||||
@Volatile private var lastWideAreaRcode: Int? = null
|
||||
@Volatile private var lastWideAreaCount: Int = 0
|
||||
|
||||
private val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onDiscoveryStarted(serviceType: String) {}
|
||||
override fun onDiscoveryStopped(serviceType: String) {}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
|
||||
resolve(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById.remove(id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
startLocalDiscovery()
|
||||
startUnicastDiscovery(wideAreaDomain)
|
||||
}
|
||||
|
||||
private fun startLocalDiscovery() {
|
||||
try {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (true) {
|
||||
try {
|
||||
refreshUnicast(domain)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||
nsd.resolveService(
|
||||
serviceInfo,
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
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 id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_bridges.value =
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
|
||||
private fun buildStatusText(): String {
|
||||
val localCount = localById.size
|
||||
val wideRcode = lastWideAreaRcode
|
||||
val wideCount = lastWideAreaCount
|
||||
|
||||
val wide =
|
||||
when (wideRcode) {
|
||||
null -> "Wide: ?"
|
||||
Rcode.NOERROR -> "Wide: $wideCount"
|
||||
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||
}
|
||||
|
||||
return when {
|
||||
localCount == 0 && wideRcode == null -> "Searching for bridges…"
|
||||
localCount == 0 -> "$wide"
|
||||
else -> "Local: $localCount • $wide"
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(serviceName: String, domain: String): String {
|
||||
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||
}
|
||||
|
||||
private fun normalizeName(raw: String): String {
|
||||
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
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>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv =
|
||||
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
?: run {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
|
||||
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
}
|
||||
?: continue
|
||||
val port = srv.port
|
||||
if (port <= 0) continue
|
||||
|
||||
val targetFqdn = srv.target.toString()
|
||||
val host =
|
||||
resolveHostFromMessage(ptrMsg, targetFqdn)
|
||||
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
|
||||
?: resolveHostUnicast(targetFqdn)
|
||||
?: continue
|
||||
|
||||
val txtFromPtr =
|
||||
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? TXTRecord }
|
||||
val txt =
|
||||
if (txtFromPtr.isNotEmpty()) {
|
||||
txtFromPtr
|
||||
} else {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
|
||||
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
|
||||
}
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
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 id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
}
|
||||
|
||||
unicastById.clear()
|
||||
unicastById.putAll(next)
|
||||
lastWideAreaRcode = ptrMsg.header.rcode
|
||||
lastWideAreaCount = next.size
|
||||
publish()
|
||||
|
||||
if (next.isEmpty()) {
|
||||
Log.d(
|
||||
logTag,
|
||||
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
|
||||
val suffix = "${serviceType}${domain}"
|
||||
val withoutSuffix =
|
||||
if (instanceFqdn.endsWith(suffix)) {
|
||||
instanceFqdn.removeSuffix(suffix)
|
||||
} else {
|
||||
instanceFqdn.substringBefore(serviceType)
|
||||
}
|
||||
return normalizeName(stripTrailingDot(withoutSuffix))
|
||||
}
|
||||
|
||||
private fun stripTrailingDot(raw: String): String {
|
||||
return raw.removeSuffix(".")
|
||||
}
|
||||
|
||||
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
|
||||
val query =
|
||||
try {
|
||||
Message.newQuery(
|
||||
org.xbill.DNS.Record.newRecord(
|
||||
Name.fromString(name),
|
||||
type,
|
||||
DClass.IN,
|
||||
),
|
||||
)
|
||||
} catch (_: TextParseException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val system = queryViaSystemDns(query)
|
||||
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||
|
||||
val direct = createDirectResolver() ?: return system
|
||||
return try {
|
||||
val msg = direct.send(query)
|
||||
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
|
||||
} catch (_: Throwable) {
|
||||
system
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryViaSystemDns(query: Message): Message? {
|
||||
val network = preferredDnsNetwork()
|
||||
val bytes =
|
||||
try {
|
||||
rawQuery(network, query.toWire())
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
Message(bytes)
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
return raw.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
|
||||
val next = LinkedHashMap<String, MutableList<Record>>()
|
||||
for (r in records(msg, section)) {
|
||||
val name = r.name?.toString() ?: continue
|
||||
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
|
||||
val key = keyName(fqdn)
|
||||
val byNameAnswer = recordsByName(msg, Section.ANSWER)
|
||||
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
|
||||
if (fromAnswer != null) return fromAnswer
|
||||
|
||||
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
|
||||
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
|
||||
}
|
||||
|
||||
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
|
||||
val m = msg ?: return null
|
||||
val key = keyName(hostname)
|
||||
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
|
||||
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredDnsNetwork(): android.net.Network? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
// Prefer VPN (Tailscale) when present; otherwise use the active network.
|
||||
cm.allNetworks.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let { return it }
|
||||
|
||||
return cm.activeNetwork
|
||||
}
|
||||
|
||||
private fun createDirectResolver(): Resolver? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
val candidateNetworks =
|
||||
buildList {
|
||||
cm.allNetworks
|
||||
.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let(::add)
|
||||
cm.activeNetwork?.let(::add)
|
||||
}.distinct()
|
||||
|
||||
val servers =
|
||||
candidateNetworks
|
||||
.asSequence()
|
||||
.flatMap { n ->
|
||||
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||
}
|
||||
.distinctBy { it.hostAddress ?: it.toString() }
|
||||
.toList()
|
||||
if (servers.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val resolvers =
|
||||
servers.mapNotNull { addr ->
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(3)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
cont.resume(answer)
|
||||
}
|
||||
|
||||
override fun onError(error: DnsResolver.DnsException) {
|
||||
cont.resumeWithException(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||
val prefix = "$key="
|
||||
for (r in records) {
|
||||
val strings: List<String> =
|
||||
try {
|
||||
r.strings.mapNotNull { it as? String }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
for (s in strings) {
|
||||
val trimmed = decodeDnsTxtString(s).trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
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.
|
||||
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
|
||||
val decoder =
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
return try {
|
||||
decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
} catch (_: Throwable) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostUnicast(hostname: String): String? {
|
||||
val a =
|
||||
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
|
||||
.mapNotNull { it as? ARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa =
|
||||
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
|
||||
.mapNotNull { it as? AAAARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.clawdis.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.clawdis.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
|
||||
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())
|
||||
|
||||
try {
|
||||
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")
|
||||
}
|
||||
} 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
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.clawdis.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.clawdis.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() }
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
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 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(
|
||||
"ClawdisBridge",
|
||||
"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 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
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package com.clawdis.android.chat
|
||||
|
||||
import com.clawdis.android.bridge.BridgeSession
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
class ChatController(
|
||||
private val scope: CoroutineScope,
|
||||
private val session: BridgeSession,
|
||||
private val json: Json,
|
||||
) {
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
|
||||
private val _sessionId = MutableStateFlow<String?>(null)
|
||||
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _errorText = MutableStateFlow<String?>(null)
|
||||
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||
|
||||
private val _healthOk = MutableStateFlow(false)
|
||||
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
|
||||
|
||||
private val _thinkingLevel = MutableStateFlow("off")
|
||||
val thinkingLevel: StateFlow<String> = _thinkingLevel.asStateFlow()
|
||||
|
||||
private val _pendingRunCount = MutableStateFlow(0)
|
||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||
|
||||
private val _streamingAssistantText = MutableStateFlow<String?>(null)
|
||||
val streamingAssistantText: StateFlow<String?> = _streamingAssistantText.asStateFlow()
|
||||
|
||||
private val pendingToolCallsById = ConcurrentHashMap<String, ChatPendingToolCall>()
|
||||
private val _pendingToolCalls = MutableStateFlow<List<ChatPendingToolCall>>(emptyList())
|
||||
val pendingToolCalls: StateFlow<List<ChatPendingToolCall>> = _pendingToolCalls.asStateFlow()
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSessionEntry>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSessionEntry>> = _sessions.asStateFlow()
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
|
||||
fun onDisconnected(message: String) {
|
||||
_healthOk.value = false
|
||||
// Not an error; keep connection status in the UI pill.
|
||||
_errorText.value = null
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
fun load(sessionKey: String = "main") {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refreshSessions(limit: Int? = null) {
|
||||
scope.launch { fetchSessions(limit = limit) }
|
||||
}
|
||||
|
||||
fun setThinkingLevel(thinkingLevel: String) {
|
||||
val normalized = normalizeThinking(thinkingLevel)
|
||||
if (normalized == _thinkingLevel.value) return
|
||||
_thinkingLevel.value = normalized
|
||||
}
|
||||
|
||||
fun switchSession(sessionKey: String) {
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
) {
|
||||
val trimmed = message.trim()
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||
if (!_healthOk.value) {
|
||||
_errorText.value = "Gateway health not OK; cannot send"
|
||||
return
|
||||
}
|
||||
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed
|
||||
val sessionKey = _sessionKey.value
|
||||
val thinking = normalizeThinking(thinkingLevel)
|
||||
|
||||
// Optimistic user message.
|
||||
val userContent =
|
||||
buildList {
|
||||
add(ChatMessageContent(type = "text", text = text))
|
||||
for (att in attachments) {
|
||||
add(
|
||||
ChatMessageContent(
|
||||
type = att.type,
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
_messages.value =
|
||||
_messages.value +
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
armPendingRunTimeout(runId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(runId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
|
||||
_errorText.value = null
|
||||
_streamingAssistantText.value = null
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
val runIds =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.toList()
|
||||
}
|
||||
if (runIds.isEmpty()) return
|
||||
scope.launch {
|
||||
for (runId in runIds) {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||
put("runId", JsonPrimitive(runId))
|
||||
}
|
||||
session.request("chat.abort", params.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
when (event) {
|
||||
"tick" -> {
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
_errorText.value = "Event stream interrupted; try refreshing."
|
||||
clearPendingRuns()
|
||||
}
|
||||
"chat" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
||||
_errorText.value = null
|
||||
_healthOk.value = false
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
_sessionId.value = null
|
||||
|
||||
val key = _sessionKey.value
|
||||
try {
|
||||
try {
|
||||
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
|
||||
pollHealthIfNeeded(force = forceHealth)
|
||||
fetchSessions(limit = 50)
|
||||
} catch (err: Throwable) {
|
||||
_errorText.value = err.message
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSessions(limit: Int?) {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("includeGlobal", JsonPrimitive(true))
|
||||
put("includeUnknown", JsonPrimitive(false))
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = session.request("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
if (!force && last != null && now - last < 10_000) return
|
||||
lastHealthPollAtMs = now
|
||||
try {
|
||||
session.request("health", null)
|
||||
_healthOk.value = true
|
||||
} catch (_: Throwable) {
|
||||
_healthOk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChatEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
|
||||
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
if (runId != null) {
|
||||
val isPending =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.contains(runId)
|
||||
}
|
||||
if (!isPending) return
|
||||
}
|
||||
|
||||
val state = payload["state"].asStringOrNull()
|
||||
when (state) {
|
||||
"final", "aborted", "error" -> {
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
scope.launch {
|
||||
try {
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
val sessionId = _sessionId.value
|
||||
if (sessionId != null && runId != sessionId) return
|
||||
|
||||
val stream = payload["stream"].asStringOrNull()
|
||||
val data = payload["data"].asObjectOrNull()
|
||||
|
||||
when (stream) {
|
||||
"assistant" -> {
|
||||
val text = data?.get("text")?.asStringOrNull()
|
||||
if (!text.isNullOrEmpty()) {
|
||||
_streamingAssistantText.value = text
|
||||
}
|
||||
}
|
||||
"tool" -> {
|
||||
val phase = data?.get("phase")?.asStringOrNull()
|
||||
val name = data?.get("name")?.asStringOrNull()
|
||||
val toolCallId = data?.get("toolCallId")?.asStringOrNull()
|
||||
if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return
|
||||
|
||||
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
||||
if (phase == "start") {
|
||||
val args = data?.get("args").asObjectOrNull()
|
||||
pendingToolCallsById[toolCallId] =
|
||||
ChatPendingToolCall(
|
||||
toolCallId = toolCallId,
|
||||
name = name,
|
||||
args = args,
|
||||
startedAtMs = ts,
|
||||
isError = null,
|
||||
)
|
||||
publishPendingToolCalls()
|
||||
} else if (phase == "result") {
|
||||
pendingToolCallsById.remove(toolCallId)
|
||||
publishPendingToolCalls()
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
_errorText.value = "Event stream interrupted; try refreshing."
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishPendingToolCalls() {
|
||||
_pendingToolCalls.value =
|
||||
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
||||
}
|
||||
|
||||
private fun armPendingRunTimeout(runId: String) {
|
||||
pendingRunTimeoutJobs[runId]?.cancel()
|
||||
pendingRunTimeoutJobs[runId] =
|
||||
scope.launch {
|
||||
delay(pendingRunTimeoutMs)
|
||||
val stillPending =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.contains(runId)
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRun(runId: String) {
|
||||
pendingRunTimeoutJobs.remove(runId)?.cancel()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.remove(runId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRuns() {
|
||||
for ((_, job) in pendingRunTimeoutJobs) {
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
array.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
|
||||
val ts = obj["timestamp"].asLongOrNull()
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = content,
|
||||
timestampMs = ts,
|
||||
)
|
||||
}
|
||||
|
||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
||||
}
|
||||
|
||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
val obj = el.asObjectOrNull() ?: return null
|
||||
val type = obj["type"].asStringOrNull() ?: "text"
|
||||
return if (type == "text") {
|
||||
ChatMessageContent(type = "text", text = obj["text"].asStringOrNull())
|
||||
} else {
|
||||
ChatMessageContent(
|
||||
type = type,
|
||||
mimeType = obj["mimeType"].asStringOrNull(),
|
||||
fileName = obj["fileName"].asStringOrNull(),
|
||||
base64 = obj["content"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? {
|
||||
return try {
|
||||
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeThinking(raw: String): String {
|
||||
return when (raw.trim().lowercase()) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.clawdis.android.chat
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val role: String,
|
||||
val content: List<ChatMessageContent>,
|
||||
val timestampMs: Long?,
|
||||
)
|
||||
|
||||
data class ChatMessageContent(
|
||||
val type: String = "text",
|
||||
val text: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val fileName: String? = null,
|
||||
val base64: String? = null,
|
||||
)
|
||||
|
||||
data class ChatPendingToolCall(
|
||||
val toolCallId: String,
|
||||
val name: String,
|
||||
val args: kotlinx.serialization.json.JsonObject? = null,
|
||||
val startedAtMs: Long,
|
||||
val isError: Boolean? = null,
|
||||
)
|
||||
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
data class ChatHistory(
|
||||
val sessionKey: String,
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
)
|
||||
|
||||
data class OutgoingAttachment(
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
val fileName: String,
|
||||
val base64: String,
|
||||
)
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.clawdis.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.core.graphics.scale
|
||||
import com.clawdis.android.PermissionRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||
lifecycleOwner = owner
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
|
||||
if (results[Manifest.permission.CAMERA] != true) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
if (results[Manifest.permission.RECORD_AUDIO] != true) {
|
||||
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snap(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
|
||||
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||
val h =
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
decoded.scale(maxWidth, h)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val recorder = Recorder.Builder().build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||
|
||||
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||
val recording: Recording =
|
||||
videoCapture.output
|
||||
.prepareRecording(context, outputOptions)
|
||||
.apply {
|
||||
if (includeAudio) withAudioEnabled()
|
||||
}
|
||||
.start(context.mainExecutor()) { event ->
|
||||
if (event is VideoRecordEvent.Finalize) {
|
||||
finalized.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
kotlinx.coroutines.delay(durationMs.toLong())
|
||||
} finally {
|
||||
recording.stop()
|
||||
}
|
||||
|
||||
val finalizeEvent =
|
||||
try {
|
||||
withTimeout(10_000) { finalized.await() }
|
||||
} catch (err: Throwable) {
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||
}
|
||||
if (finalizeEvent.hasError()) {
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip failed")
|
||||
}
|
||||
|
||||
val bytes = file.readBytes()
|
||||
file.delete()
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
paramsJson?.contains("\"back\"") == true -> "back"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseQuality(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
try {
|
||||
cont.resume(future.get())
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
takePicture(
|
||||
options,
|
||||
executor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
cont.resumeWithException(exception)
|
||||
}
|
||||
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
try {
|
||||
val bytes = file.readBytes()
|
||||
cont.resume(bytes)
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.clawdis.android.node
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import com.clawdis.android.BuildConfig
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CanvasController {
|
||||
enum class SnapshotFormat(val rawValue: String) {
|
||||
Png("png"),
|
||||
Jpeg("jpeg"),
|
||||
}
|
||||
|
||||
@Volatile private var webView: WebView? = null
|
||||
@Volatile private var url: String? = null
|
||||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
private fun clampJpegQuality(quality: Double?): Int {
|
||||
val q = (quality ?: 0.82).coerceIn(0.1, 1.0)
|
||||
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
val trimmed = url.trim()
|
||||
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun setDebugStatus(title: String?, subtitle: String?) {
|
||||
debugStatusTitle = title
|
||||
debugStatusSubtitle = subtitle
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
val wv = webView ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block(wv)
|
||||
} else {
|
||||
wv.post { block(wv) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
val currentUrl = url
|
||||
withWebViewOnMain { wv ->
|
||||
if (currentUrl == null) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdisCanvas", "load scaffold: $scaffoldAssetUrl")
|
||||
}
|
||||
wv.loadUrl(scaffoldAssetUrl)
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdisCanvas", "load url: $currentUrl")
|
||||
}
|
||||
wv.loadUrl(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDebugStatus() {
|
||||
val enabled = debugStatusEnabled
|
||||
val title = debugStatusTitle
|
||||
val subtitle = debugStatusSubtitle
|
||||
withWebViewOnMain { wv ->
|
||||
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
|
||||
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
|
||||
val js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__clawdis;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
||||
}
|
||||
if (!${if (enabled) "true" else "false"}) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus($titleJs, $subtitleJs);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun eval(javaScript: String): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
suspendCancellableCoroutine { cont ->
|
||||
wv.evaluateJavascript(javaScript) { result ->
|
||||
cont.resume(result ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val width = width.coerceAtLeast(1)
|
||||
val height = height.coerceAtLeast(1)
|
||||
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||
draw(Canvas(bitmap))
|
||||
cont.resume(bitmap)
|
||||
}
|
||||
|
||||
companion object {
|
||||
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
|
||||
|
||||
fun parseNavigateUrl(paramsJson: String?): String {
|
||||
val obj = parseParamsObject(paramsJson) ?: return ""
|
||||
return obj.string("url").trim()
|
||||
}
|
||||
|
||||
fun parseEvalJs(paramsJson: String?): String? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
val js = obj.string("javaScript").trim()
|
||||
return js.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
if (!obj.containsKey("maxWidth")) return null
|
||||
val width = obj.int("maxWidth") ?: 0
|
||||
return width.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat {
|
||||
val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg
|
||||
val raw = obj.string("format").trim().lowercase()
|
||||
return when (raw) {
|
||||
"png" -> SnapshotFormat.Png
|
||||
"jpeg", "jpg" -> SnapshotFormat.Jpeg
|
||||
"" -> SnapshotFormat.Jpeg
|
||||
else -> SnapshotFormat.Jpeg
|
||||
}
|
||||
}
|
||||
|
||||
fun parseSnapshotQuality(paramsJson: String?): Double? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
if (!obj.containsKey("quality")) return null
|
||||
val q = obj.double("quality") ?: Double.NaN
|
||||
if (!q.isFinite()) return null
|
||||
return q.coerceIn(0.1, 1.0)
|
||||
}
|
||||
|
||||
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
|
||||
return SnapshotParams(
|
||||
format = parseSnapshotFormat(paramsJson),
|
||||
quality = parseSnapshotQuality(paramsJson),
|
||||
maxWidth = parseSnapshotMaxWidth(paramsJson),
|
||||
)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
return try {
|
||||
json.parseToJsonElement(raw).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonObject.string(key: String): String {
|
||||
val prim = this[key] as? JsonPrimitive ?: return ""
|
||||
val raw = prim.content
|
||||
return raw.takeIf { it != "null" }.orEmpty()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(key: String): Int? {
|
||||
val prim = this[key] as? JsonPrimitive ?: return null
|
||||
return prim.content.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(key: String): Double? {
|
||||
val prim = this[key] as? JsonPrimitive ?: return null
|
||||
return prim.content.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.clawdis.android.node
|
||||
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class JpegSizeLimiterResult(
|
||||
val bytes: ByteArray,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val quality: Int,
|
||||
)
|
||||
|
||||
internal object JpegSizeLimiter {
|
||||
fun compressToLimit(
|
||||
initialWidth: Int,
|
||||
initialHeight: Int,
|
||||
startQuality: Int,
|
||||
maxBytes: Int,
|
||||
minQuality: Int = 20,
|
||||
minSize: Int = 256,
|
||||
scaleStep: Double = 0.85,
|
||||
maxScaleAttempts: Int = 6,
|
||||
maxQualityAttempts: Int = 6,
|
||||
encode: (width: Int, height: Int, quality: Int) -> ByteArray,
|
||||
): JpegSizeLimiterResult {
|
||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
||||
|
||||
var width = initialWidth
|
||||
var height = initialHeight
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
|
||||
if (best.bytes.size <= maxBytes) return best
|
||||
|
||||
repeat(maxScaleAttempts) {
|
||||
var quality = clampedStartQuality
|
||||
repeat(maxQualityAttempts) {
|
||||
val bytes = encode(width, height, quality)
|
||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
if (bytes.size <= maxBytes) return best
|
||||
if (quality <= minQuality) return@repeat
|
||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
||||
}
|
||||
|
||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
||||
val nextScale = max(scaleStep, minScale)
|
||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
||||
val nextHeight = max(minSize, (height * nextScale).roundToInt())
|
||||
if (nextWidth == width && nextHeight == height) return@repeat
|
||||
width = min(nextWidth, width)
|
||||
height = min(nextHeight, height)
|
||||
}
|
||||
|
||||
if (best.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.clawdis.android.node
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.util.Base64
|
||||
import com.clawdis.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ScreenRecordManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||
@Volatile private var permissionRequester: com.clawdis.android.PermissionRequester? = null
|
||||
|
||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||
screenCaptureRequester = requester
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: com.clawdis.android.PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun record(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Default) {
|
||||
val requester =
|
||||
screenCaptureRequester
|
||||
?: throw IllegalStateException(
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
val screenIndex = parseScreenIndex(paramsJson)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val format = parseString(paramsJson, key = "format")
|
||||
if (format != null && format.lowercase() != "mp4") {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
if (screenIndex != null && screenIndex != 0) {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
|
||||
}
|
||||
|
||||
val capture = requester.requestCapture()
|
||||
?: throw IllegalStateException(
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val mgr =
|
||||
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
|
||||
?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
|
||||
|
||||
val metrics = context.resources.displayMetrics
|
||||
val width = metrics.widthPixels
|
||||
val height = metrics.heightPixels
|
||||
val densityDpi = metrics.densityDpi
|
||||
|
||||
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val recorder = MediaRecorder()
|
||||
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||
try {
|
||||
if (includeAudio) {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
}
|
||||
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
||||
if (includeAudio) {
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setAudioSamplingRate(44_100)
|
||||
recorder.setAudioEncodingBitRate(96_000)
|
||||
}
|
||||
recorder.setVideoSize(width, height)
|
||||
recorder.setVideoFrameRate(fpsInt)
|
||||
recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
|
||||
recorder.setOutputFile(file.absolutePath)
|
||||
recorder.prepare()
|
||||
|
||||
val surface = recorder.surface
|
||||
virtualDisplay =
|
||||
projection.createVirtualDisplay(
|
||||
"clawdis-screen",
|
||||
width,
|
||||
height,
|
||||
densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
recorder.start()
|
||||
delay(durationMs.toLong())
|
||||
} finally {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
recorder.reset()
|
||||
recorder.release()
|
||||
virtualDisplay?.release()
|
||||
projection.stop()
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) { file.readBytes() }
|
||||
file.delete()
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.RECORD_AUDIO,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester =
|
||||
permissionRequester
|
||||
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
|
||||
if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
|
||||
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseFps(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
|
||||
}
|
||||
|
||||
private fun parseString(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
if (!tail.startsWith('\"')) return null
|
||||
val rest = tail.drop(1)
|
||||
val end = rest.indexOf('\"')
|
||||
if (end < 0) return null
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
val raw = (pixels * fps.toLong() * 2L).toInt()
|
||||
return raw.coerceIn(1_000_000, 12_000_000)
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.clawdis.android.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object ClawdisCanvasA2UIAction {
|
||||
fun extractActionName(userAction: JsonObject): String? {
|
||||
val name =
|
||||
(userAction["name"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (name.isNotEmpty()) return name
|
||||
val action =
|
||||
(userAction["action"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return action.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
val out = StringBuilder(normalized.length)
|
||||
for (c in normalized) {
|
||||
val ok =
|
||||
c.isLetterOrDigit() ||
|
||||
c == '_' ||
|
||||
c == '-' ||
|
||||
c == '.' ||
|
||||
c == ':'
|
||||
out.append(if (ok) c else '_')
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJson: String?,
|
||||
): String {
|
||||
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||
return listOf(
|
||||
"CANVAS_A2UI",
|
||||
"action=${sanitizeTagValue(actionName)}",
|
||||
"session=${sanitizeTagValue(sessionKey)}",
|
||||
"surface=${sanitizeTagValue(surfaceId)}",
|
||||
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||
"host=${sanitizeTagValue(host)}",
|
||||
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||
"default=update_canvas",
|
||||
).joinToString(separator = " ")
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.clawdis.android.protocol
|
||||
|
||||
enum class ClawdisCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
VoiceWake("voiceWake"),
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||
Present("canvas.present"),
|
||||
Hide("canvas.hide"),
|
||||
Navigate("canvas.navigate"),
|
||||
Eval("canvas.eval"),
|
||||
Snapshot("canvas.snapshot"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
||||
Push("canvas.a2ui.push"),
|
||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||
Reset("canvas.a2ui.reset"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas.a2ui."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisCameraCommand(val rawValue: String) {
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "camera."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisScreenCommand(val rawValue: String) {
|
||||
Record("screen.record"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.clawdis.android.tools
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplayActionSpec(
|
||||
val label: String? = null,
|
||||
val detailKeys: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplaySpec(
|
||||
val emoji: String? = null,
|
||||
val title: String? = null,
|
||||
val label: String? = null,
|
||||
val detailKeys: List<String>? = null,
|
||||
val actions: Map<String, ToolDisplayActionSpec>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplayConfig(
|
||||
val version: Int? = null,
|
||||
val fallback: ToolDisplaySpec? = null,
|
||||
val tools: Map<String, ToolDisplaySpec>? = null,
|
||||
)
|
||||
|
||||
data class ToolDisplaySummary(
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val title: String,
|
||||
val label: String,
|
||||
val verb: String?,
|
||||
val detail: String?,
|
||||
) {
|
||||
val detailLine: String?
|
||||
get() {
|
||||
val parts = mutableListOf<String>()
|
||||
if (!verb.isNullOrBlank()) parts.add(verb)
|
||||
if (!detail.isNullOrBlank()) parts.add(detail)
|
||||
return if (parts.isEmpty()) null else parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
val summaryLine: String
|
||||
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
|
||||
}
|
||||
|
||||
object ToolDisplayRegistry {
|
||||
private const val CONFIG_ASSET = "tool-display.json"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@Volatile private var cachedConfig: ToolDisplayConfig? = null
|
||||
|
||||
fun resolve(
|
||||
context: Context,
|
||||
name: String?,
|
||||
args: JsonObject?,
|
||||
meta: String? = null,
|
||||
): ToolDisplaySummary {
|
||||
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
|
||||
val key = trimmedName.lowercase()
|
||||
val config = loadConfig(context)
|
||||
val spec = config.tools?.get(key)
|
||||
val fallback = config.fallback
|
||||
|
||||
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
|
||||
val title = spec?.title ?: titleFromName(trimmedName)
|
||||
val label = spec?.label ?: trimmedName
|
||||
|
||||
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
|
||||
val action = actionRaw?.takeIf { it.isNotEmpty() }
|
||||
val actionSpec = action?.let { spec?.actions?.get(it) }
|
||||
val verb = normalizeVerb(actionSpec?.label ?: action)
|
||||
|
||||
var detail: String? = null
|
||||
if (key == "read") {
|
||||
detail = readDetail(args)
|
||||
} else if (key == "write" || key == "edit" || key == "attach") {
|
||||
detail = pathDetail(args)
|
||||
}
|
||||
|
||||
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
|
||||
if (detail == null) {
|
||||
detail = firstValue(args, detailKeys)
|
||||
}
|
||||
|
||||
if (detail == null) {
|
||||
detail = meta
|
||||
}
|
||||
|
||||
if (detail != null) {
|
||||
detail = shortenHomeInString(detail)
|
||||
}
|
||||
|
||||
return ToolDisplaySummary(
|
||||
name = trimmedName,
|
||||
emoji = emoji,
|
||||
title = title,
|
||||
label = label,
|
||||
verb = verb,
|
||||
detail = detail,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadConfig(context: Context): ToolDisplayConfig {
|
||||
val existing = cachedConfig
|
||||
if (existing != null) return existing
|
||||
return try {
|
||||
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
|
||||
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
|
||||
cachedConfig = decoded
|
||||
decoded
|
||||
} catch (_: Throwable) {
|
||||
val fallback = ToolDisplayConfig()
|
||||
cachedConfig = fallback
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleFromName(name: String): String {
|
||||
val cleaned = name.replace("_", " ").trim()
|
||||
if (cleaned.isEmpty()) return "Tool"
|
||||
return cleaned
|
||||
.split(Regex("\\s+"))
|
||||
.joinToString(" ") { part ->
|
||||
val upper = part.uppercase()
|
||||
if (part.length <= 2 && part == upper) part
|
||||
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeVerb(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return trimmed.replace("_", " ")
|
||||
}
|
||||
|
||||
private fun readDetail(args: JsonObject?): String? {
|
||||
val path = args?.get("path")?.asStringOrNull() ?: return null
|
||||
val offset = args["offset"].asNumberOrNull()
|
||||
val limit = args["limit"].asNumberOrNull()
|
||||
return if (offset != null && limit != null) {
|
||||
val end = offset + limit
|
||||
"${path}:${offset.toInt()}-${end.toInt()}"
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
private fun pathDetail(args: JsonObject?): String? {
|
||||
return args?.get("path")?.asStringOrNull()
|
||||
}
|
||||
|
||||
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
|
||||
for (key in keys) {
|
||||
val value = valueForPath(args, key)
|
||||
val rendered = renderValue(value)
|
||||
if (!rendered.isNullOrBlank()) return rendered
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
|
||||
var current: JsonElement? = args
|
||||
for (segment in path.split(".")) {
|
||||
if (segment.isBlank()) return null
|
||||
val obj = current as? JsonObject ?: return null
|
||||
current = obj[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun renderValue(value: JsonElement?): String? {
|
||||
if (value == null) return null
|
||||
if (value is JsonPrimitive) {
|
||||
if (value.isString) {
|
||||
val trimmed = value.contentOrNull?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
if (firstLine.isEmpty()) return null
|
||||
return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine
|
||||
}
|
||||
val raw = value.contentOrNull?.trim().orEmpty()
|
||||
raw.toBooleanStrictOrNull()?.let { return it.toString() }
|
||||
raw.toLongOrNull()?.let { return it.toString() }
|
||||
raw.toDoubleOrNull()?.let { return it.toString() }
|
||||
}
|
||||
if (value is JsonArray) {
|
||||
val items = value.mapNotNull { renderValue(it) }
|
||||
if (items.isEmpty()) return null
|
||||
val preview = items.take(3).joinToString(", ")
|
||||
return if (items.size > 3) "${preview}…" else preview
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun shortenHomeInString(value: String): String {
|
||||
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
|
||||
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
|
||||
if (home.isNullOrEmpty()) return value
|
||||
return value.replace(home, "~")
|
||||
.replace(Regex("/Users/[^/]+"), "~")
|
||||
.replace(Regex("/home/[^/]+"), "~")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asNumberOrNull(): Double? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
val raw = primitive.contentOrNull ?: return null
|
||||
return raw.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.graphics.Color
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun CameraFlashOverlay(
|
||||
token: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CameraFlash(token = token)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraFlash(token: Long) {
|
||||
var alpha by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(token) {
|
||||
if (token == 0L) return@LaunchedEffect
|
||||
alpha = 0.85f
|
||||
delay(110)
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(alpha)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.clawdis.android.MainViewModel
|
||||
import com.clawdis.android.ui.chat.ChatSheetContent
|
||||
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
ChatSheetContent(viewModel = viewModel)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun ClawdisTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun overlayIconColor(): Color {
|
||||
return MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.webkit.WebSettingsCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdis.android.CameraHudKind
|
||||
import com.clawdis.android.MainViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScreen(viewModel: MainViewModel) {
|
||||
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
val context = LocalContext.current
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||
val screenRecordActive by viewModel.screenRecordActive.collectAsState()
|
||||
val isForeground by viewModel.isForeground.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
||||
val talkStatusText by viewModel.talkStatusText.collectAsState()
|
||||
val talkIsListening by viewModel.talkIsListening.collectAsState()
|
||||
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
|
||||
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
||||
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) viewModel.setTalkEnabled(true)
|
||||
}
|
||||
val activity =
|
||||
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if (!isForeground) {
|
||||
return@remember StatusActivity(
|
||||
title = "Foreground required",
|
||||
icon = Icons.Default.Report,
|
||||
contentDescription = "Foreground required",
|
||||
)
|
||||
}
|
||||
|
||||
val lowerStatus = statusText.lowercase()
|
||||
if (lowerStatus.contains("repair")) {
|
||||
return@remember StatusActivity(
|
||||
title = "Repairing…",
|
||||
icon = Icons.Default.Refresh,
|
||||
contentDescription = "Repairing",
|
||||
)
|
||||
}
|
||||
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
|
||||
return@remember StatusActivity(
|
||||
title = "Approval pending",
|
||||
icon = Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Approval pending",
|
||||
)
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
|
||||
if (screenRecordActive) {
|
||||
return@remember StatusActivity(
|
||||
title = "Recording screen…",
|
||||
icon = Icons.Default.ScreenShare,
|
||||
contentDescription = "Recording screen",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
cameraHud?.let { hud ->
|
||||
return@remember when (hud.kind) {
|
||||
CameraHudKind.Photo ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.PhotoCamera,
|
||||
contentDescription = "Taking photo",
|
||||
)
|
||||
CameraHudKind.Recording ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.FiberManualRecord,
|
||||
contentDescription = "Recording",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
CameraHudKind.Success ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.CheckCircle,
|
||||
contentDescription = "Capture finished",
|
||||
)
|
||||
CameraHudKind.Error ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.Error,
|
||||
contentDescription = "Capture failed",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
|
||||
return@remember StatusActivity(
|
||||
title = "Mic permission",
|
||||
icon = Icons.Default.Error,
|
||||
contentDescription = "Mic permission required",
|
||||
)
|
||||
}
|
||||
if (voiceWakeStatusText == "Paused") {
|
||||
val suffix = if (!isForeground) " (background)" else ""
|
||||
return@remember StatusActivity(
|
||||
title = "Voice Wake paused$suffix",
|
||||
icon = Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Voice Wake paused",
|
||||
)
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
val bridgeState =
|
||||
remember(serverName, statusText) {
|
||||
when {
|
||||
serverName != null -> BridgeState.Connected
|
||||
statusText.contains("connecting", ignoreCase = true) ||
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||
else -> BridgeState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
val voiceEnabled =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Camera flash must be in a Popup to render above the WebView.
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// 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,
|
||||
voiceEnabled = voiceEnabled,
|
||||
activity = activity,
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
||||
Column(
|
||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Chat },
|
||||
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
||||
)
|
||||
|
||||
// Talk mode gets a dedicated side bubble instead of burying it in settings.
|
||||
val baseOverlay = overlayContainerColor()
|
||||
val talkContainer =
|
||||
lerp(
|
||||
baseOverlay,
|
||||
seamColor.copy(alpha = baseOverlay.alpha),
|
||||
if (talkEnabled) 0.35f else 0.22f,
|
||||
)
|
||||
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
|
||||
OverlayIconButton(
|
||||
onClick = {
|
||||
val next = !talkEnabled
|
||||
if (next) {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setTalkEnabled(true)
|
||||
} else {
|
||||
viewModel.setTalkEnabled(false)
|
||||
}
|
||||
},
|
||||
containerColor = talkContainer,
|
||||
contentColor = talkContent,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Talk Mode",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (talkEnabled) {
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
TalkOrbOverlay(
|
||||
seamColor = seamColor,
|
||||
statusText = talkStatusText,
|
||||
isListening = talkIsListening,
|
||||
isSpeaking = talkIsSpeaking,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val currentSheet = sheet
|
||||
if (currentSheet != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheet = null },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
when (currentSheet) {
|
||||
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Sheet {
|
||||
Chat,
|
||||
Settings,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayIconButton(
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
containerColor: ComposeColor? = null,
|
||||
contentColor: ComposeColor? = null,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = containerColor ?: overlayContainerColor(),
|
||||
contentColor = contentColor ?: overlayIconColor(),
|
||||
),
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
}
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdisWebView", "userAgent: ${settings.userAgentString}")
|
||||
}
|
||||
isScrollContainer = true
|
||||
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
isVerticalScrollBarEnabled = true
|
||||
isHorizontalScrollBarEnabled = true
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
errorResponse: WebResourceResponse,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e(
|
||||
"ClawdisWebView",
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdisWebView", "onPageFinished: $url")
|
||||
}
|
||||
viewModel.canvas.onPageFinished()
|
||||
}
|
||||
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
detail: android.webkit.RenderProcessGoneDetail,
|
||||
): Boolean {
|
||||
if (isDebuggable) {
|
||||
Log.e(
|
||||
"ClawdisWebView",
|
||||
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
webChromeClient =
|
||||
object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
if (!isDebuggable) return false
|
||||
val msg = consoleMessage ?: return false
|
||||
Log.d(
|
||||
"ClawdisWebView",
|
||||
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Use default layer/background; avoid forcing a black fill over WebView content.
|
||||
|
||||
val a2uiBridge =
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
}
|
||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
val msg = payload?.trim().orEmpty()
|
||||
if (msg.isEmpty()) return
|
||||
onMessage(msg)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
||||
@JavascriptInterface
|
||||
fun canvasAction(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "Android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdis.android.BuildConfig
|
||||
import com.clawdis.android.MainViewModel
|
||||
import com.clawdis.android.NodeForegroundService
|
||||
import com.clawdis.android.VoiceWakeMode
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.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 discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||
val deviceModel =
|
||||
remember {
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { "Android" }
|
||||
}
|
||||
val appVersion =
|
||||
remember {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||
viewModel.setCameraEnabled(cameraOk)
|
||||
}
|
||||
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// Status text is handled by NodeRuntime.
|
||||
}
|
||||
|
||||
fun setCameraEnabledChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setCameraEnabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
val cameraOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (cameraOk) {
|
||||
viewModel.setCameraEnabled(true)
|
||||
} else {
|
||||
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||
}
|
||||
}
|
||||
|
||||
val visibleBridges =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
} else {
|
||||
bridges
|
||||
}
|
||||
|
||||
val bridgeDiscoveryFooterText =
|
||||
if (visibleBridges.isEmpty()) {
|
||||
discoveryStatusText
|
||||
} else if (isConnected) {
|
||||
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
} else {
|
||||
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = viewModel::setDisplayName,
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Bridge
|
||||
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
|
||||
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
||||
if (serverName != null) {
|
||||
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
||||
}
|
||||
if (remoteAddress != null) {
|
||||
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
|
||||
}
|
||||
item {
|
||||
// UI sanity: "Disconnect" only when we have an active remote.
|
||||
if (isConnected && remoteAddress != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
NodeForegroundService.stop(context)
|
||||
},
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
if (!isConnected || visibleBridges.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) "Other Bridges" else "Discovered Bridges",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (!isConnected && visibleBridges.isEmpty()) {
|
||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} else {
|
||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||
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")
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(bridge.name) },
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
detailLines.forEach { line ->
|
||||
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
NodeForegroundService.start(context)
|
||||
viewModel.connect(bridge)
|
||||
},
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
bridgeDiscoveryFooterText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Advanced") },
|
||||
supportingContent = { Text("Manual bridge connection") },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (advancedExpanded) "Collapse" else "Expand",
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
setAdvancedExpanded(!advancedExpanded)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(visible = advancedExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Use Manual Bridge") },
|
||||
supportingContent = { Text("Use this when discovery is blocked.") },
|
||||
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = manualHost,
|
||||
onValueChange = viewModel::setManualHost,
|
||||
label = { Text("Host") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = manualPort.toString(),
|
||||
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
|
||||
label = { Text("Port") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
|
||||
val hostOk = manualHost.trim().isNotEmpty()
|
||||
val portOk = manualPort in 1..65535
|
||||
Button(
|
||||
onClick = {
|
||||
NodeForegroundService.start(context)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
enabled = manualEnabled && hostOk && portOk,
|
||||
) {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Voice
|
||||
item { Text("Voice", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
val enabled = voiceWakeMode != VoiceWakeMode.Off
|
||||
ListItem(
|
||||
headlineContent = { Text("Voice Wake") },
|
||||
supportingContent = { Text(voiceWakeStatusText) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { on ->
|
||||
if (on) {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
} else {
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Foreground Only") },
|
||||
supportingContent = { Text("Listens only while Clawdis is open.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Always") },
|
||||
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Always,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = wakeWordsText,
|
||||
onValueChange = setWakeWordsText,
|
||||
label = { Text("Wake Words (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = com.clawdis.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||
viewModel.setWakeWords(parsed)
|
||||
},
|
||||
enabled = isConnected,
|
||||
) {
|
||||
Text("Save + Sync")
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) {
|
||||
"Any node can edit wake words. Changes sync via the gateway bridge."
|
||||
} else {
|
||||
"Connect to a gateway to sync wake words globally."
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Camera
|
||||
item { Text("Camera", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Allow Camera") },
|
||||
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
|
||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Tip: grant Microphone permission for video clips with audio.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Screen
|
||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Prevent Sleep") },
|
||||
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
|
||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Debug
|
||||
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Debug Canvas Status") },
|
||||
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = canvasDebugStatusEnabled,
|
||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatusPill(
|
||||
bridge: BridgeState,
|
||||
voiceEnabled: Boolean,
|
||||
activity: StatusActivity? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = overlayContainerColor(),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
modifier = Modifier.size(9.dp),
|
||||
shape = CircleShape,
|
||||
color = bridge.color,
|
||||
) {}
|
||||
|
||||
Text(
|
||||
text = bridge.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider(
|
||||
modifier = Modifier.height(14.dp).alpha(0.35f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (activity != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = activity.icon,
|
||||
contentDescription = activity.contentDescription,
|
||||
tint = activity.tint ?: overlayIconColor(),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = activity.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||
tint =
|
||||
if (voiceEnabled) {
|
||||
overlayIconColor()
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusActivity(
|
||||
val title: String,
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val tint: Color? = null,
|
||||
)
|
||||
|
||||
enum class BridgeState(val title: String, val color: Color) {
|
||||
Connected("Connected", Color(0xFF2ECC71)),
|
||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||
Error("Error", Color(0xFFE74C3C)),
|
||||
Disconnected("Offline", Color(0xFF9E9E9E)),
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.clawdis.android.ui
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TalkOrbOverlay(
|
||||
seamColor: Color,
|
||||
statusText: String,
|
||||
isListening: Boolean,
|
||||
isSpeaking: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "talk-orb")
|
||||
val t by
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "pulse",
|
||||
)
|
||||
|
||||
val trimmed = statusText.trim()
|
||||
val showStatus = trimmed.isNotEmpty() && trimmed != "Off"
|
||||
val phase =
|
||||
when {
|
||||
isSpeaking -> "Speaking"
|
||||
isListening -> "Listening"
|
||||
else -> "Thinking"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Canvas(modifier = Modifier.size(360.dp)) {
|
||||
val center = this.center
|
||||
val baseRadius = size.minDimension * 0.30f
|
||||
|
||||
val ring1 = 1.05f + (t * 0.25f)
|
||||
val ring2 = 1.20f + (t * 0.55f)
|
||||
val ringAlpha1 = (1f - t) * 0.34f
|
||||
val ringAlpha2 = (1f - t) * 0.22f
|
||||
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = ringAlpha1),
|
||||
radius = baseRadius * ring1,
|
||||
center = center,
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
)
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = ringAlpha2),
|
||||
radius = baseRadius * ring2,
|
||||
center = center,
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
seamColor.copy(alpha = 0.92f),
|
||||
seamColor.copy(alpha = 0.40f),
|
||||
Color.Black.copy(alpha = 0.56f),
|
||||
),
|
||||
center = center,
|
||||
radius = baseRadius * 1.35f,
|
||||
),
|
||||
radius = baseRadius,
|
||||
center = center,
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = 0.34f),
|
||||
radius = baseRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.40f),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text(
|
||||
text = trimmed,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = phase,
|
||||
color = Color.White.copy(alpha = 0.80f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdis.android.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
errorText: String?,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
onSelectSession: (sessionKey: String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.displayName ?: entry.key) },
|
||||
onClick = {
|
||||
onSelectSession(entry.key)
|
||||
showSessionMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (entry.key == sessionKey) {
|
||||
Text("✓")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showThinkingMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Message Clawd…") },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onAbort,
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0x33E74C3C),
|
||||
contentColor = Color(0xFFE74C3C),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Stop, contentDescription = "Abort")
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(onClick = {
|
||||
val text = input
|
||||
input = ""
|
||||
onSend(text)
|
||||
}, enabled = canSend) {
|
||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorText.isNullOrBlank()) {
|
||||
Text(
|
||||
text = errorText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(7.dp),
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||
) {}
|
||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (healthOk) "Connected" else "Connecting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingMenuItem(
|
||||
value: String,
|
||||
current: String,
|
||||
onSet: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(thinkingLabel(value)) },
|
||||
onClick = {
|
||||
onSet(value)
|
||||
onDismiss()
|
||||
},
|
||||
trailingIcon = {
|
||||
if (value == current.trim().lowercase()) {
|
||||
Text("✓")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun thinkingLabel(raw: String): String {
|
||||
return when (raw.trim().lowercase()) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentsStrip(
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (att in attachments) {
|
||||
AttachmentChip(
|
||||
fileName = att.fileName,
|
||||
onRemove = { onRemoveAttachment(att.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||
FilledTonalIconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier.size(30.dp),
|
||||
) {
|
||||
Text("×")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
val blocks = remember(text) { splitMarkdown(text) }
|
||||
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (b in blocks) {
|
||||
when (b) {
|
||||
is ChatMarkdownBlock.Text -> {
|
||||
val trimmed = b.text.trimEnd()
|
||||
if (trimmed.isEmpty()) continue
|
||||
Text(
|
||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
is ChatMarkdownBlock.Code -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = b.code, language = b.language)
|
||||
}
|
||||
}
|
||||
is ChatMarkdownBlock.InlineImage -> {
|
||||
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ChatMarkdownBlock {
|
||||
data class Text(val text: String) : ChatMarkdownBlock
|
||||
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
|
||||
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
|
||||
}
|
||||
|
||||
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
var idx = 0
|
||||
while (idx < raw.length) {
|
||||
val fenceStart = raw.indexOf("```", startIndex = idx)
|
||||
if (fenceStart < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx)))
|
||||
break
|
||||
}
|
||||
|
||||
if (fenceStart > idx) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
|
||||
}
|
||||
|
||||
val langLineStart = fenceStart + 3
|
||||
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
|
||||
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
|
||||
|
||||
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
|
||||
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
|
||||
if (fenceEnd < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(fenceStart)))
|
||||
break
|
||||
}
|
||||
val code = raw.substring(codeStart, fenceEnd)
|
||||
out.add(ChatMarkdownBlock.Code(code = code, language = language))
|
||||
|
||||
idx = fenceEnd + 3
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
|
||||
var idx = 0
|
||||
while (idx < text.length) {
|
||||
val m = regex.find(text, startIndex = idx) ?: break
|
||||
val start = m.range.first
|
||||
val end = m.range.last + 1
|
||||
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
|
||||
|
||||
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
|
||||
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
|
||||
if (b64.isNotEmpty()) {
|
||||
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
|
||||
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
|
||||
if (text.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val out = buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
if (text.startsWith("**", startIndex = i)) {
|
||||
val end = text.indexOf("**", startIndex = i + 2)
|
||||
if (end > i + 2) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append(text.substring(i + 2, end))
|
||||
}
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '`') {
|
||||
val end = text.indexOf('`', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = inlineCodeBg,
|
||||
),
|
||||
) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
|
||||
val end = text.indexOf('*', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
append(text[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image!!,
|
||||
contentDescription = mimeType ?: "image",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (failed) {
|
||||
Text(
|
||||
text = "Image unavailable",
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowCircleDown
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdis.android.chat.ChatMessage
|
||||
import com.clawdis.android.chat.ChatPendingToolCall
|
||||
|
||||
@Composable
|
||||
fun ChatMessageListCard(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
||||
val total =
|
||||
messages.size +
|
||||
(if (pendingRunCount > 0) 1 else 0) +
|
||||
(if (pendingToolCalls.isNotEmpty()) 1 else 0) +
|
||||
(if (!streamingAssistantText.isNullOrBlank()) 1 else 0)
|
||||
if (total <= 0) return@LaunchedEffect
|
||||
listState.animateScrollToItem(index = total - 1)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
|
||||
) {
|
||||
items(count = messages.size, key = { idx -> messages[idx].id }) { idx ->
|
||||
ChatMessageBubble(message = messages[idx])
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
val stream = streamingAssistantText?.trim()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||
EmptyChatHint(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChatHint(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.alpha(0.7f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowCircleDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Message Clawd…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import com.clawdis.android.chat.ChatMessage
|
||||
import com.clawdis.android.chat.ChatMessageContent
|
||||
import com.clawdis.android.chat.ChatPendingToolCall
|
||||
import com.clawdis.android.tools.ToolDisplayRegistry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun ChatMessageBubble(message: ChatMessage) {
|
||||
val isUser = message.role.lowercase() == "user"
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.fillMaxWidth(0.92f),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(bubbleBackground(isUser))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
val textColor = textColorOverBubble(isUser)
|
||||
ChatMessageBody(content = message.content, textColor = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (part in content) {
|
||||
when (part.type) {
|
||||
"text" -> {
|
||||
val text = part.text ?: continue
|
||||
ChatMarkdown(text = text, textColor = textColor)
|
||||
}
|
||||
else -> {
|
||||
val b64 = part.base64 ?: continue
|
||||
ChatBase64Image(base64 = b64, mimeType = part.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTypingIndicatorBubble() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DotPulse()
|
||||
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
val context = LocalContext.current
|
||||
val displays =
|
||||
remember(toolCalls, context) {
|
||||
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
for (display in displays.take(6)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
"${display.emoji} ${display.label}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
display.detailLine?.let { detail ->
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toolCalls.size > 6) {
|
||||
Text(
|
||||
"… +${toolCalls.size - 6} more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStreamingAssistantBubble(text: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bubbleBackground(isUser: Boolean): Brush {
|
||||
return if (isUser) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun textColorOverBubble(isUser: Boolean): Color {
|
||||
return if (isUser) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image!!,
|
||||
contentDescription = mimeType ?: "attachment",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (failed) {
|
||||
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DotPulse() {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
PulseDot(alpha = 0.38f)
|
||||
PulseDot(alpha = 0.62f)
|
||||
PulseDot(alpha = 0.90f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulseDot(alpha: Float) {
|
||||
Surface(
|
||||
modifier = Modifier.size(6.dp).alpha(alpha),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatCodeBlock(code: String, language: String?) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = code.trimEnd(),
|
||||
modifier = Modifier.padding(10.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdis.android.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatSessionsDialog(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
onDismiss: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelect: (sessionKey: String) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Sessions", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FilledTonalIconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
if (sessions.isEmpty()) {
|
||||
Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(sessions, key = { it.key }) { entry ->
|
||||
SessionRow(
|
||||
entry = entry,
|
||||
isCurrent = entry.key == currentSessionKey,
|
||||
onClick = { onSelect(entry.key) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
entry: ChatSessionEntry,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color =
|
||||
if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isCurrent) {
|
||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdis.android.MainViewModel
|
||||
import com.clawdis.android.chat.OutgoingAttachment
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val errorText by viewModel.chatError.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.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")
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
|
||||
|
||||
val pickImages =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val next =
|
||||
uris.take(8).mapNotNull { uri ->
|
||||
try {
|
||||
loadImageAttachment(resolver, uri)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
attachments.addAll(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ChatMessageListCard(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
errorText = errorText,
|
||||
attachments = attachments,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
onSelectSession = { key -> viewModel.switchChatSession(key) },
|
||||
onRefresh = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
},
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
attachments.map { att ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
)
|
||||
}
|
||||
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||
attachments.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PendingImageAttachment(
|
||||
val id: String,
|
||||
val fileName: String,
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
||||
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
||||
val bytes =
|
||||
withContext(Dispatchers.IO) {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
input.copyTo(out)
|
||||
out.toByteArray()
|
||||
} ?: ByteArray(0)
|
||||
}
|
||||
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
return PendingImageAttachment(
|
||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||
fileName = fileName,
|
||||
mimeType = mimeType,
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.clawdis.android.ui.chat
|
||||
|
||||
import com.clawdis.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>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
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 (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
}
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
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)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
if (included.add(entry.key)) {
|
||||
result.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isNotEmpty() && !included.contains(current)) {
|
||||
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.clawdis.android.voice
|
||||
|
||||
import android.media.MediaDataSource
|
||||
import kotlin.math.min
|
||||
|
||||
internal class StreamingMediaDataSource : MediaDataSource() {
|
||||
private data class Chunk(val start: Long, val data: ByteArray)
|
||||
|
||||
private val lock = Object()
|
||||
private val chunks = ArrayList<Chunk>()
|
||||
private var totalSize: Long = 0
|
||||
private var closed = false
|
||||
private var finished = false
|
||||
private var lastReadIndex = 0
|
||||
|
||||
fun append(data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
synchronized(lock) {
|
||||
if (closed || finished) return
|
||||
val chunk = Chunk(totalSize, data)
|
||||
chunks.add(chunk)
|
||||
totalSize += data.size.toLong()
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
synchronized(lock) {
|
||||
if (closed) return
|
||||
finished = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun fail() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position < 0) return -1
|
||||
synchronized(lock) {
|
||||
while (!closed && !finished && position >= totalSize) {
|
||||
lock.wait()
|
||||
}
|
||||
if (closed) return -1
|
||||
if (position >= totalSize && finished) return -1
|
||||
|
||||
val available = (totalSize - position).toInt()
|
||||
val toRead = min(size, available)
|
||||
var remaining = toRead
|
||||
var destOffset = offset
|
||||
var pos = position
|
||||
|
||||
var index = findChunkIndex(pos)
|
||||
while (remaining > 0 && index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
val inChunkOffset = (pos - chunk.start).toInt()
|
||||
if (inChunkOffset >= chunk.data.size) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
|
||||
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
|
||||
remaining -= copyLen
|
||||
destOffset += copyLen
|
||||
pos += copyLen
|
||||
if (inChunkOffset + copyLen >= chunk.data.size) {
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
return toRead - remaining
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(): Long = -1
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findChunkIndex(position: Long): Int {
|
||||
var index = lastReadIndex
|
||||
while (index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
if (position < chunk.start + chunk.data.size) break
|
||||
index++
|
||||
}
|
||||
lastReadIndex = index
|
||||
return index
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.clawdis.android.voice
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
private val directiveJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
data class TalkDirective(
|
||||
val voiceId: String? = null,
|
||||
val modelId: String? = null,
|
||||
val speed: Double? = null,
|
||||
val rateWpm: Int? = null,
|
||||
val stability: Double? = null,
|
||||
val similarity: Double? = null,
|
||||
val style: Double? = null,
|
||||
val speakerBoost: Boolean? = null,
|
||||
val seed: Long? = null,
|
||||
val normalize: String? = null,
|
||||
val language: String? = null,
|
||||
val outputFormat: String? = null,
|
||||
val latencyTier: Int? = null,
|
||||
val once: Boolean? = null,
|
||||
)
|
||||
|
||||
data class TalkDirectiveParseResult(
|
||||
val directive: TalkDirective?,
|
||||
val stripped: String,
|
||||
val unknownKeys: List<String>,
|
||||
)
|
||||
|
||||
object TalkDirectiveParser {
|
||||
fun parse(text: String): TalkDirectiveParseResult {
|
||||
val normalized = text.replace("\r\n", "\n")
|
||||
val lines = normalized.split("\n").toMutableList()
|
||||
if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList())
|
||||
|
||||
val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() }
|
||||
if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList())
|
||||
|
||||
val head = lines[firstNonEmpty].trim()
|
||||
if (!head.startsWith("{") || !head.endsWith("}")) {
|
||||
return TalkDirectiveParseResult(null, text, emptyList())
|
||||
}
|
||||
|
||||
val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList())
|
||||
|
||||
val speakerBoost =
|
||||
boolValue(obj, listOf("speaker_boost", "speakerBoost"))
|
||||
?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not()
|
||||
|
||||
val directive = TalkDirective(
|
||||
voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")),
|
||||
modelId = stringValue(obj, listOf("model", "model_id", "modelId")),
|
||||
speed = doubleValue(obj, listOf("speed")),
|
||||
rateWpm = intValue(obj, listOf("rate", "wpm")),
|
||||
stability = doubleValue(obj, listOf("stability")),
|
||||
similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")),
|
||||
style = doubleValue(obj, listOf("style")),
|
||||
speakerBoost = speakerBoost,
|
||||
seed = longValue(obj, listOf("seed")),
|
||||
normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")),
|
||||
language = stringValue(obj, listOf("lang", "language_code", "language")),
|
||||
outputFormat = stringValue(obj, listOf("output_format", "format")),
|
||||
latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")),
|
||||
once = boolValue(obj, listOf("once")),
|
||||
)
|
||||
|
||||
val hasDirective = listOf(
|
||||
directive.voiceId,
|
||||
directive.modelId,
|
||||
directive.speed,
|
||||
directive.rateWpm,
|
||||
directive.stability,
|
||||
directive.similarity,
|
||||
directive.style,
|
||||
directive.speakerBoost,
|
||||
directive.seed,
|
||||
directive.normalize,
|
||||
directive.language,
|
||||
directive.outputFormat,
|
||||
directive.latencyTier,
|
||||
directive.once,
|
||||
).any { it != null }
|
||||
|
||||
if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList())
|
||||
|
||||
val knownKeys = setOf(
|
||||
"voice", "voice_id", "voiceid",
|
||||
"model", "model_id", "modelid",
|
||||
"speed", "rate", "wpm",
|
||||
"stability", "similarity", "similarity_boost", "similarityboost",
|
||||
"style",
|
||||
"speaker_boost", "speakerboost",
|
||||
"no_speaker_boost", "nospeakerboost",
|
||||
"seed",
|
||||
"normalize", "apply_text_normalization",
|
||||
"lang", "language_code", "language",
|
||||
"output_format", "format",
|
||||
"latency", "latency_tier", "latencytier",
|
||||
"once",
|
||||
)
|
||||
val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted()
|
||||
|
||||
lines.removeAt(firstNonEmpty)
|
||||
if (firstNonEmpty < lines.size) {
|
||||
if (lines[firstNonEmpty].trim().isEmpty()) {
|
||||
lines.removeAt(firstNonEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys)
|
||||
}
|
||||
|
||||
private fun parseJsonObject(line: String): JsonObject? {
|
||||
return try {
|
||||
directiveJson.parseToJsonElement(line) as? JsonObject
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun stringValue(obj: JsonObject, keys: List<String>): String? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asStringOrNull()?.trim()
|
||||
if (!value.isNullOrEmpty()) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun doubleValue(obj: JsonObject, keys: List<String>): Double? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asDoubleOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun intValue(obj: JsonObject, keys: List<String>): Int? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asIntOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun longValue(obj: JsonObject, keys: List<String>): Long? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asLongOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun boolValue(obj: JsonObject, keys: List<String>): Boolean? {
|
||||
for (key in keys) {
|
||||
val value = obj[key].asBooleanOrNull()
|
||||
if (value != null) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun JsonElement?.asDoubleOrNull(): Double? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return primitive.content.toDoubleOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asIntOrNull(): Int? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return primitive.content.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return primitive.content.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
val content = primitive.content.trim().lowercase()
|
||||
return when (content) {
|
||||
"true", "yes", "1" -> true
|
||||
"false", "no", "0" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1408 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user