Compare commits
910
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -7,20 +7,39 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: [node, bun]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Node version
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.x"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
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 +60,63 @@ 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:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install 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: swift build --package-path apps/macos --configuration release
|
||||
|
||||
- name: Swift tests
|
||||
run: swift test --package-path apps/macos --parallel
|
||||
@@ -4,3 +4,12 @@ dist
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
**/.DS_Store
|
||||
apps/macos/.build/
|
||||
bin/clawdis-mac
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
|
||||
@@ -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
|
||||
+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,15 @@
|
||||
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
||||
|
||||
# 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,6 +18,7 @@
|
||||
- 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`.
|
||||
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
@@ -24,31 +27,41 @@
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
|
||||
## 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/Tau 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.
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- 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.
|
||||
- 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 `warelay send` with messages containing exclamation marks, use heredoc syntax:
|
||||
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
|
||||
warelay send --provider web --to "+1234" --message 'Hello!'
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdis send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
warelay send --provider web --to "+1234" --message "$(cat <<'EOF'
|
||||
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a warelay bug.
|
||||
This is a Claude Code quirk, not a clawdis bug.
|
||||
+150
-103
@@ -1,133 +1,180 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0 — Unreleased
|
||||
## 2.0.0 — Unreleased
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** New `inbound.reply.agent` block chooses the CLI and parser per command reply; per-agent argv builders inject the right flags/identity/prompt handling and parse NDJSON streams, enabling Pi/Codex swaps without changing templates.
|
||||
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.
|
||||
|
||||
### Bug Fixes
|
||||
- **Empty result field handling:** Fixed bug where Claude CLI returning `result: ""` (empty string) would cause raw JSON to be sent to WhatsApp instead of being treated as valid empty output. Changed truthy check to explicit type check in `command-reply.ts`.
|
||||
- **Response prefix on heartbeat replies:** Fixed `responsePrefix` (e.g., `🦞`) not being applied to heartbeat alert messages. The prefix was only applied in the regular message handler, not in `runReplyHeartbeat`.
|
||||
- **User-visible error messages:** Command failures (non-zero exit, killed processes, exceptions) now return user-friendly error messages to WhatsApp instead of silently failing with empty responses.
|
||||
- **Test session isolation:** Fixed tests corrupting production `sessions.json` by mocking session persistence in all test files.
|
||||
- **Signal session corruption prevention:** Added IPC mechanism so `warelay send` and `warelay heartbeat` reuse the running relay's WhatsApp connection instead of creating new Baileys sockets. Previously, using these commands while the relay was running could corrupt the Signal session ratchet (both connections wrote to the same auth state), causing the relay's subsequent sends to fail silently.
|
||||
### 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/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, 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 background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 Tau/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.
|
||||
- CLI gateway now auto-starts WhatsApp and Telegram when configured (single `gateway` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented.
|
||||
|
||||
## 1.5.0 — 2025-12-05
|
||||
|
||||
### Breaking
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` 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
|
||||
- **IPC server for relay:** The web relay now starts a Unix socket server at `~/.warelay/relay.sock`. Commands like `warelay send --provider web` automatically connect via IPC when the relay is running, falling back to direct connection otherwise.
|
||||
- **Batched inbound messaging with timestamps:** When multiple WhatsApp messages queue up, they’re sent to the agent in one combined batch, each line timestamped consistently to preserve ordering and context.
|
||||
- **Typing indicator after IPC send:** After sending a message via IPC (e.g., `warelay send`), the relay now automatically shows the typing indicator ("composing") to signal that more messages may be coming.
|
||||
- **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives.
|
||||
- **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter.
|
||||
- **Test isolation improvements:** Mock `loadConfig()` in all test files to prevent loading real user config (with emojis/prefixes) during tests. Default test config now has no prefixes/timestamps for cleaner assertions.
|
||||
- **Same-phone mode (self-messaging):** warelay now supports running on the same phone number you message from. This enables setups where you chat with yourself to control an AI assistant. Same-phone mode (`from === to`) is always allowed, even without configuring `allowFrom`. Echo detection prevents infinite loops by tracking recently sent message text and skipping auto-replies when incoming messages match.
|
||||
- **Echo detection:** The `fromMe` filter in `inbound.ts` is deliberately removed for same-phone setups; instead, text-based echo detection in `auto-reply.ts` tracks sent messages in a bounded Set (max 100 entries) and skips processing when a match is found.
|
||||
- **Same-phone detection logging:** Verbose mode now logs `📱 Same-phone mode detected` when `from === to`.
|
||||
- **Configurable same-phone marker:** New `inbound.samePhoneMarker` config option to customize the prefix added to messages in same-phone mode (default: `[same-phone]`). Set it to something cute like `[🦞 same-phone]` to help distinguish bot replies.
|
||||
- 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.
|
||||
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files 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 > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--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/Tau, 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/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau 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.
|
||||
- **Tau completion signal:** RPC now resolves on Tau’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.
|
||||
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Tau (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):** `inbound.reply.agent` selects CLI/parser; 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 `inbound.samePhoneMarker`.
|
||||
|
||||
## 1.2.2 — 2025-11-28
|
||||
|
||||
### Changes
|
||||
- **Manual heartbeat sends:** `warelay heartbeat` accepts `--message/--body` with `--provider web|twilio` to push real outbound messages through the same plumbing; `--dry-run` previews payloads without sending.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
- **Heartbeat backpressure:** Web reply heartbeats now check the shared command queue and skip while any command/Claude runs are in flight, preventing concurrent prompts during long-running requests.
|
||||
- **Isolated session fixtures in web tests:** Heartbeat/auto-reply tests now create temporary session stores instead of using the default `~/.warelay/sessions.json`, preventing local config pollution during test runs.
|
||||
- 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:** Media loading now sniffs magic bytes/header before trusting extensions for both providers; local files with the wrong suffix still get correct MIME and image recompression.
|
||||
- **Hosted media extensions:** Saved/hosted media (web inbound, webhook hosting, Twilio hosting) now writes files with an extension derived from detected MIME (e.g., `.jpg`, `.png`, `.mp4`), so downstream CLI sends carry the right Content-Type. Added tests covering inbound Baileys downloads and buffer saves.
|
||||
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
||||
|
||||
### Planned / in progress
|
||||
- **Heartbeat targeting quality:** Allow `warelay heartbeat --provider web --all` to fall back to `inbound.allowFrom` when no sessions exist, and surface a clear error when neither sessions nor allow-list entries are present. Add verbose log lines that state exactly which recipients were chosen and why.
|
||||
- **Heartbeat delivery preview (Claude path):** Add a dry-run mode that resolves the heartbeat reply (text/media) and prints it without sending, to help test Claude prompt changes safely.
|
||||
- **Simulated inbound hook (debug):** Optional local-only endpoint to inject synthetic inbound messages into the web relay loop, sharing the same command queue and reply path. Useful for testing auto-replies and heartbeats without WhatsApp.
|
||||
### 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 UX:** Default heartbeat interval is now 10 minutes for command mode. Heartbeat prompt is `HEARTBEAT ultrathink`; replies of exactly `HEARTBEAT_OK` suppress outbound messages but still log. Fallback heartbeats no longer start fresh sessions when none exist, and skipped heartbeats do not refresh session `updatedAt` (so idle expiry still works). Session-level `heartbeatIdleMinutes` is supported.
|
||||
- **Heartbeat tooling:** `warelay heartbeat` accepts `--session-id` to force resume a specific Claude session. Added `--heartbeat-now` to relay startup, plus helper scripts `warelay relay:heartbeat` and `warelay relay:heartbeat:tmux` to fire a heartbeat immediately when the relay launches.
|
||||
- **Prompt structure for Claude:** Introduced one-time `sessionIntro` (system prompt) with per-message `bodyPrefix` of `ultrathink`, so the full prompt is sent only on the first turn; later turns only prepend `ultrathink`. Session idle extended to 7 days (configurable).
|
||||
- **Robustness:** Added WebSocket error guards for Baileys sessions; global `unhandledRejection`/`uncaughtException` handlers log and exit cleanly. Web inbound now resolves WhatsApp Linked IDs (`@lid`) using Baileys reverse mapping. Media hosting during Twilio webhooks uses the shared host module and is covered by tests.
|
||||
- **Docs:** README now highlights the Clawd setup with links, and `docs/claude-config.md` contains the live personal config (home folder, prompts, heartbeat behavior, and session settings).
|
||||
- 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 now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
||||
- Web provider now detects media kind (image/audio/video/document), logs the source path, and enforces provider caps: images ≤6 MB, audio/video ≤16 MB, documents ≤100 MB; images still target the configurable cap above with resize + JPEG recompress.
|
||||
- Sessions can now send the system prompt only once: set `inbound.reply.session.sendSystemOnce` (optional `sessionIntro` for the first turn) to avoid re-sending large prompts every message.
|
||||
- While commands run, typing indicators refresh every 30s by default (tune with `inbound.reply.typingIntervalSeconds`); helps keep WhatsApp “composing” visible during longer Claude runs.
|
||||
- Optional voice-note transcription: set `inbound.transcribeAudio.command` (e.g., OpenAI Whisper CLI) to turn inbound audio into text before templating/Claude; verbose logs surface when transcription runs. Prompts now include the original media path plus a `Transcript:` block so models see both.
|
||||
- Auto-reply command replies now return structured `{ payload, meta }`, respect `mediaMaxMb` for local media, log Claude metadata, and include the command `cwd` in timeout messages for easier debugging.
|
||||
- Added unit coverage for command helper edge cases (Claude flags, session args, media tokens, timeouts) and transcription download/command invocation.
|
||||
- Split the monolithic web provider into focused modules under `src/web/` plus a barrel; added logout command, no-fallback relay behavior, and web-only relay start helper.
|
||||
- Introduced structured reconnect/heartbeat logging (`web-reconnect`, `web-heartbeat`), bounded exponential backoff with CLI and config knobs, and a troubleshooting guide at `docs/refactor/web-relay-troubleshooting.md`.
|
||||
- Relay help now prints effective heartbeat/backoff settings when running in web mode for quick triage.
|
||||
- Web auto-replies resize/recompress media and honor `inbound.reply.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 `inbound.reply.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
|
||||
- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress.
|
||||
- Added tests covering the new timeout fallback behavior and partial-output truncation.
|
||||
- Web relay auto-reconnects after Baileys/WebSocket drops (with log-out detection) and exposes close events for monitoring; added tests for close propagation and reconnect loop.
|
||||
- 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
|
||||
|
||||
### Features
|
||||
- Added `cwd` option to command reply config for setting the working directory where commands execute. Essential for Claude Code to have proper project context.
|
||||
- Added configurable file-based logging (default `/tmp/warelay/warelay.log`) with log level set via `logging.level` in `~/.warelay/warelay.json`; verbose still forces debug.
|
||||
|
||||
### Developer notes
|
||||
- Command auto-replies now pass `{ timeoutMs, cwd }` into the command runner; custom runners/tests that stub `runCommandWithTimeout` should accept the options object as well as the legacy numeric timeout.
|
||||
|
||||
## 0.1.2 — 2025-11-25
|
||||
|
||||
### CI/build fix
|
||||
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
|
||||
|
||||
## 0.1.1 — 2025-11-25
|
||||
|
||||
### CLI polish
|
||||
- Added a proper executable shim so `npx warelay@0.1.x --help` runs the CLI directly.
|
||||
- Help/version banner now uses the README tagline with color, and the help footer includes colored examples with short explanations.
|
||||
- `send` and `status` gained a `--verbose` flag for consistent noisy output when debugging.
|
||||
- Lowercased branding in docs/UA; web provider UA is `warelay/cli/0.1.1`.
|
||||
|
||||
|
||||
## 0.1.0 — 2025-11-25
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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`).
|
||||
|
||||
### 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`).
|
||||
### Changes
|
||||
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
||||
- Added tests for timeout fallback and partial-output truncation.
|
||||
@@ -1,215 +1,200 @@
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp.
|
||||
# 🦞 CLAWDIS — WhatsApp & Telegram Gateway for AI Agents
|
||||
|
||||
<p align="center">
|
||||
<img src="README-header.png" alt="warelay header" width="640">
|
||||
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></a>
|
||||
<strong>EXFOLIATE! EXFOLIATE!</strong>
|
||||
</p>
|
||||
|
||||
<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://www.npmjs.com/package/clawdis"><img src="https://img.shields.io/npm/v/clawdis.svg?style=for-the-badge" alt="npm version"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
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).
|
||||
**CLAWDIS** is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
|
||||
|
||||
### Clawd (personal assistant)
|
||||
I'm using warelay to run my personal, pro-active assistant, **Clawd**. Follow me on Twitter: [@steipete](https://twitter.com/steipete). This project is brand-new and there's a lot to discover. See the exact Claude setup in [`docs/clawd.md`](https://github.com/steipete/warelay/blob/main/docs/clawd.md).
|
||||
```
|
||||
┌─────────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
||||
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau) │
|
||||
│ (You) │ ◀─── │ │ │ │
|
||||
└─────────────┘ └──────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
I'm using warelay to run **my personal, pro-active assistant, Clawd**.
|
||||
Follow me on Twitter - @steipete, this project is brand-new and there's a lot to discover.
|
||||
## Why "CLAWDIS"?
|
||||
|
||||
## Quick Start (pick your engine)
|
||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||
**CLAWDIS** = CLAW + TARDIS
|
||||
|
||||
**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` (uses Web when you're logged in; if you're not linked, start it with `--provider twilio`). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login.
|
||||
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
|
||||
|
||||
**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`
|
||||
## Features
|
||||
|
||||
> Already developing locally? You can still run `pnpm install` and `pnpm warelay ...` from the repo, but end users only need the npm package.
|
||||
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
|
||||
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
||||
- 🤖 **AI Agent Gateway** — Pi/Tau only (Pi CLI in RPC mode)
|
||||
- 💬 **Session Management** — Per-sender conversation context
|
||||
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||
- 👥 **Group Chat Support** — Mention-based triggering
|
||||
- 📎 **Media Support** — Images, audio, documents, voice notes
|
||||
- 🎤 **Voice Transcription** — Whisper integration
|
||||
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
||||
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, on-device Voice Wake, model/config editor
|
||||
|
||||
## 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.
|
||||
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
|
||||
|
||||
## 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 heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--session-id <uuid?>` `--all` `--verbose` |
|
||||
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
||||
| `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
||||
| `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` |
|
||||
## Quick Start
|
||||
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
|
||||
|
||||
### Sending media
|
||||
- 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 per file because of the built-in host).
|
||||
- Web: `warelay send --provider web --media ./pic.jpg --message "Hi"` (local path or URL; no hosting needed). Web auto-detects media kind: images (≤6 MB), audio/voice or video (≤16 MB), other docs (≤100 MB). Images are resized to max 2048px and JPEG recompressed when the cap would be exceeded.
|
||||
- Auto-replies can attach `mediaUrl` in `~/.warelay/warelay.json` (used alongside `text` when present). Web auto-replies honor `inbound.reply.mediaMaxMb` (default 5 MB) as a post-compression target but will never exceed the provider hard limits above.
|
||||
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
|
||||
|
||||
### Voice notes (optional transcription)
|
||||
- If you set `inbound.transcribeAudio.command`, warelay will run that CLI when inbound audio arrives (e.g., WhatsApp voice notes) and replace the Body with the transcript before templating/Claude.
|
||||
- Example using OpenAI Whisper CLI (requires `OPENAI_API_KEY`):
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
transcribeAudio: {
|
||||
command: [
|
||||
"openai",
|
||||
"api",
|
||||
"audio.transcriptions.create",
|
||||
"-m",
|
||||
"whisper-1",
|
||||
"-f",
|
||||
"{{MediaPath}}",
|
||||
"--response-format",
|
||||
"text"
|
||||
],
|
||||
timeoutSeconds: 45
|
||||
},
|
||||
reply: { mode: "command", command: ["claude", "{{Body}}"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
- Works for Web and Twilio providers; verbose mode logs when transcription runs. The command prompt includes the original media path plus a `Transcript:` block so models see both. If transcription fails, the original Body is used.
|
||||
```bash
|
||||
# Install
|
||||
npm install -g clawdis
|
||||
|
||||
## 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). If the Web socket closes, the relay exits instead of pivoting to Twilio.
|
||||
- **Auto-select (`relay` only):** `--provider auto` picks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops.
|
||||
# Link your WhatsApp
|
||||
clawdis login
|
||||
|
||||
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.
|
||||
# Send a message
|
||||
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
|
||||
|
||||
### Same-phone mode (self-messaging)
|
||||
warelay supports running on the same phone number you message from—you chat with yourself and an AI assistant replies in the same bubble. This requires:
|
||||
- Adding your own number to `allowFrom` in `warelay.json`
|
||||
- The `fromMe` filter is disabled; echo detection in `auto-reply.ts` prevents loops
|
||||
# Talk directly to the agent (no WhatsApp send)
|
||||
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
|
||||
|
||||
**Gotchas:**
|
||||
- Messages appear in the same chat bubble (WhatsApp "Note to self")
|
||||
- Echo detection relies on exact text matching; if the reply is identical to your input, it may be skipped
|
||||
- Works best with a dedicated WhatsApp account
|
||||
# Start the gateway (WebSocket control plane)
|
||||
clawdis gateway --port 18789 --verbose
|
||||
|
||||
# If the port is busy, force-kill listeners then start
|
||||
clawdis gateway --force
|
||||
```
|
||||
|
||||
## macOS Companion App (Clawdis.app)
|
||||
|
||||
- **On-device Voice Wake:** listens for wake words (e.g. “Claude”) using Apple’s on-device speech recognizer (macOS 26+). macOS still shows the standard Speech/Mic permissions prompt, but audio stays on device.
|
||||
- **Push-to-talk (Right Option hold):** hold right Option to speak; the voice overlay shows live partials and sends when you release.
|
||||
- **Config tab:** pick the model from your local Pi model catalog (`pi-mono/packages/ai/src/models.generated.ts`), or enter a custom model ID; edit session store path and context tokens.
|
||||
- **Voice settings:** language + additional languages, mic picker, live level meter, trigger-word table, and a built-in test harness.
|
||||
- **Menu bar toggle:** enable/disable Voice Wake from the menu bar; respects Dock-icon preference.
|
||||
|
||||
### Voice Wake reply routing
|
||||
|
||||
Voice Wake sends messages into the `main` session and replies on the **last used surface**:
|
||||
|
||||
- WhatsApp: last direct message you sent/received.
|
||||
- Telegram: last DM chat id (bot mode).
|
||||
- WebChat: last WebChat thread you used.
|
||||
|
||||
If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis logs the error and you can still inspect the run via WebChat/session logs.
|
||||
|
||||
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
|
||||
|
||||
## 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):
|
||||
Create `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
allowFrom: ["+12345550000"],
|
||||
allowFrom: ["+1234567890"],
|
||||
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 },
|
||||
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
|
||||
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
idleMinutes: 1440
|
||||
},
|
||||
heartbeatMinutes: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Heartbeat pings (command mode)
|
||||
- When `heartbeatMinutes` is set (default 10 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||
- Heartbeat body is `HEARTBEAT ultrathink` (so the model can recognize the probe); if Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
||||
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--session-id <uuid>` to force resuming a specific Claude session, `--all` to ping every active session, `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`.
|
||||
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
|
||||
## Documentation
|
||||
|
||||
### Logging (optional)
|
||||
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
||||
- Override in `~/.warelay/warelay.json`:
|
||||
- [Configuration Guide](./docs/configuration.md)
|
||||
- [Agent Integration](./docs/agents.md)
|
||||
- [Group Chats](./docs/group-messages.md)
|
||||
- [Security](./docs/security.md)
|
||||
- [Troubleshooting](./docs/troubleshooting.md)
|
||||
- [The Lore](./docs/lore.md) 🦞
|
||||
- [Telegram (Bot API)](./docs/telegram.md)
|
||||
|
||||
```json5
|
||||
{
|
||||
logging: {
|
||||
level: "warn",
|
||||
file: "/tmp/warelay/custom.log"
|
||||
}
|
||||
}
|
||||
## Clawd
|
||||
|
||||
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
|
||||
|
||||
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
|
||||
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
|
||||
- 👨💻 **Peter's Blog:** [steipete.me](https://steipete.me)
|
||||
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
|
||||
|
||||
## Provider
|
||||
|
||||
### WhatsApp Web
|
||||
```bash
|
||||
clawdis login # Scan QR code
|
||||
clawdis gateway # Start listening (WS on 127.0.0.1:18789)
|
||||
```
|
||||
|
||||
### 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). Set `sendSystemOnce: true` (plus an optional `sessionIntro`) to only send that prompt on the first turn of each session.
|
||||
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.
|
||||
### Telegram (Bot API)
|
||||
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis gateway` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits.
|
||||
|
||||
### Auto-reply parameter table (compact)
|
||||
| Key | Type & default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. |
|
||||
| `inbound.messagePrefix` | `string` (default: `"[warelay]"` if no allowFrom, else `""`) | Prefix added to all inbound messages before passing to command. |
|
||||
| `inbound.responsePrefix` | `string` (default: —) | Prefix auto-added to all outbound replies (e.g., `"🦞"`). |
|
||||
| `inbound.timestampPrefix` | `boolean \| string` (default: `true`) | Timestamp prefix: `true` (UTC), `false` (disabled), or IANA timezone like `"Europe/Vienna"`. |
|
||||
| `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.sendSystemOnce` | `boolean` (default: `false`) | If `true`, only include the system prompt/template on the first turn of a session. |
|
||||
| `inbound.reply.session.sessionIntro` | `string` | Optional intro text sent once per new session (prepended before the body when `sendSystemOnce` is used). |
|
||||
| `inbound.reply.typingIntervalSeconds` | `number` (default: `8` for command replies) | How often to refresh typing indicators while the command/Claude run is in flight. |
|
||||
| `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. |
|
||||
## Commands
|
||||
|
||||
Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `clawdis login` | Link WhatsApp Web via QR |
|
||||
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). Always uses the Gateway WS; requires a running gateway. |
|
||||
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
||||
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
|
||||
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
|
||||
| `clawdis status` | Web session health + session store summary |
|
||||
| `clawdis health` | Reports cached provider state from the running gateway. |
|
||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||
|
||||
## 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.
|
||||
#### Gateway client params (WS only)
|
||||
- `--url` (default `ws://127.0.0.1:18789`)
|
||||
- `--token` (shared secret if set on the gateway)
|
||||
- `--timeout <ms>` (WS call timeout)
|
||||
|
||||
## 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.
|
||||
#### Send
|
||||
- `--provider whatsapp|telegram` (default whatsapp)
|
||||
- `--media <path-or-url>`
|
||||
- `--json` for machine-readable output
|
||||
|
||||
### Maintainer notes (web provider internals)
|
||||
- Web logic lives under `src/web/`: `session.ts` (auth/cache + provider pick), `login.ts` (QR login/logout), `outbound.ts`/`inbound.ts` (send/receive plumbing), `auto-reply.ts` (relay loop + reconnect/backoff), `media.ts` (download/resize helpers), and `reconnect.ts` (shared retry math). `test-helpers.ts` provides fixtures.
|
||||
- The public surface remains the `src/provider-web.ts` barrel so existing imports keep working.
|
||||
- Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking.
|
||||
#### Health
|
||||
- Reads gateway/provider state (no direct Baileys socket from the CLI).
|
||||
|
||||
## FAQ & Safety
|
||||
- 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 stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`).
|
||||
- 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.
|
||||
In chat, send `/status` to see if the agent is reachable, how much context the session has used, and the current thinking/verbose toggles—no agent call required.
|
||||
`/status` also shows whether your WhatsApp web session is linked and how long ago the creds were refreshed so you know when to re-scan the QR.
|
||||
|
||||
### Sessions, surfaces, and WebChat
|
||||
|
||||
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
||||
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
||||
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
|
||||
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
||||
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
||||
- Telegram: `[Telegram Ada Lovelace (@ada_bot) id:123456789 2025-12-09 12:34] …`
|
||||
- WebChat: `[WebChat my-mac.local 10.0.0.5 2025-12-09 12:34] …`
|
||||
This keeps the model aware of the transport, sender, host, and time without relying on implicit context.
|
||||
|
||||
## Credits
|
||||
|
||||
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
|
||||
- **Clawd** 🦞 — The space lobster who demanded a better name
|
||||
|
||||
## License
|
||||
|
||||
MIT — Free as a lobster in the ocean.
|
||||
|
||||
---
|
||||
|
||||
*"We're all just playing with our own prompts."*
|
||||
|
||||
🦞💙
|
||||
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,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" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.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,36 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v26),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.executableTarget(
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
]),
|
||||
],
|
||||
swiftLanguageModes: [.v6])
|
||||
@@ -0,0 +1,107 @@
|
||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||
|
||||
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries.
|
||||
|
||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||
- **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` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]),
|
||||
]
|
||||
```
|
||||
|
||||
## 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; wake gating is string match on partial/final.
|
||||
- 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,111 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
case transcriberUnavailable
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
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,70 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@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,84 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@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 lowered = text.lowercased()
|
||||
if lowered.contains(cfg.wake.word.lowercased()) { return true }
|
||||
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
var out = text
|
||||
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive])
|
||||
for alias in cfg.wake.aliases {
|
||||
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -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,99 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
@@ -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,32 @@
|
||||
# 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.
|
||||
|
||||
## 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`.
|
||||
- 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**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
|
||||
- **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 (text match only for now).
|
||||
|
||||
## 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"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Clawdis Updates</title>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,6 @@
|
||||
parent_config: ../../.swiftlint.yml
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# ClawdisNode (iOS)
|
||||
|
||||
Internal-only SwiftUI app scaffold.
|
||||
|
||||
## Lint/format (required)
|
||||
```bash
|
||||
brew install swiftformat swiftlint
|
||||
```
|
||||
|
||||
## Generate the Xcode project
|
||||
```bash
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
open ClawdisNode.xcodeproj
|
||||
```
|
||||
|
||||
## Shared packages
|
||||
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||
@@ -0,0 +1,121 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
actor BridgeClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
nodeId: String,
|
||||
displayName: String?,
|
||||
platform: String,
|
||||
version: String,
|
||||
existingToken: String?) async throws -> String
|
||||
{
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
|
||||
connection.start(queue: queue)
|
||||
|
||||
let token = existingToken
|
||||
try await self.send(
|
||||
BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: platform,
|
||||
version: version),
|
||||
over: connection)
|
||||
|
||||
if let line = try await self.receiveLine(over: connection),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
{
|
||||
if base.type == "hello-ok" {
|
||||
connection.cancel()
|
||||
return existingToken ?? ""
|
||||
}
|
||||
if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
|
||||
try await self.send(
|
||||
BridgePairRequest(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
platform: platform,
|
||||
version: version),
|
||||
over: connection)
|
||||
|
||||
while let next = try await self.receiveLine(over: connection) {
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
|
||||
if nextBase.type == "pair-ok" {
|
||||
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
|
||||
connection.cancel()
|
||||
return ok.token
|
||||
}
|
||||
if nextBase.type == "error" {
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
|
||||
connection.cancel()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.cancel()
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine(over connection: NWConnection) async throws -> String? {
|
||||
var buffer = Data()
|
||||
while true {
|
||||
if let idx = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = buffer.prefix(upTo: idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk(over: connection)
|
||||
if chunk.isEmpty { return nil }
|
||||
buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@MainActor
|
||||
final class BridgeDiscoveryModel: ObservableObject {
|
||||
struct DiscoveredBridge: Identifiable, Equatable {
|
||||
var id: String { self.debugID }
|
||||
var name: String
|
||||
var endpoint: NWEndpoint
|
||||
var debugID: String
|
||||
}
|
||||
|
||||
@Published var bridges: [DiscoveredBridge] = []
|
||||
@Published var statusText: String = "Idle"
|
||||
|
||||
private var browser: NWBrowser?
|
||||
|
||||
func start() {
|
||||
if self.browser != nil { return }
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: ClawdisBonjour.bridgeServiceDomain),
|
||||
using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .setup:
|
||||
self.statusText = "Setup"
|
||||
case .ready:
|
||||
self.statusText = "Searching…"
|
||||
case let .failed(err):
|
||||
self.statusText = "Failed: \(err)"
|
||||
case .cancelled:
|
||||
self.statusText = "Stopped"
|
||||
case let .waiting(err):
|
||||
self.statusText = "Waiting: \(err)"
|
||||
@unknown default:
|
||||
self.statusText = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.bridges = results.compactMap { result -> DiscoveredBridge? in
|
||||
switch result.endpoint {
|
||||
case let .service(name, _, _, _):
|
||||
return DiscoveredBridge(
|
||||
name: name,
|
||||
endpoint: result.endpoint,
|
||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
self.browser = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery"))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.browser?.cancel()
|
||||
self.browser = nil
|
||||
self.bridges = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
||||
self.decodeBonjourEscapes(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func decodeBonjourEscapes(_ input: String) -> String {
|
||||
// mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI.
|
||||
var out = ""
|
||||
var i = input.startIndex
|
||||
while i < input.endIndex {
|
||||
if input[i] == "\\",
|
||||
let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)),
|
||||
let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)),
|
||||
let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)),
|
||||
input[d0].isNumber,
|
||||
input[d1].isNumber,
|
||||
input[d2].isNumber
|
||||
{
|
||||
let digits = String(input[d0...d2])
|
||||
if let value = Int(digits),
|
||||
let scalar = UnicodeScalar(value)
|
||||
{
|
||||
out.append(Character(scalar))
|
||||
i = input.index(i, offsetBy: 4)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.append(input[i])
|
||||
i = input.index(after: i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
actor BridgeSession {
|
||||
enum State: Sendable, Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case connected(serverName: String)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var queue: DispatchQueue?
|
||||
private var buffer = Data()
|
||||
|
||||
private(set) var state: State = .idle
|
||||
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.state = .connecting
|
||||
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-session")
|
||||
self.connection = connection
|
||||
self.queue = queue
|
||||
connection.start(queue: queue)
|
||||
|
||||
try await self.send(hello)
|
||||
|
||||
guard let line = try await self.receiveLine(),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
if base.type == "hello-ok" {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
await onConnected?(ok.serverName)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
} else {
|
||||
self.state = .failed(message: "Unexpected bridge response")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
while true {
|
||||
guard let next = try await self.receiveLine() else { break }
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||
|
||||
switch nextBase.type {
|
||||
case "ping":
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
|
||||
case "invoke":
|
||||
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||
let res = await onInvoke(req)
|
||||
try await self.send(res)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await self.disconnect()
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async throws {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
self.state = .idle
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
guard let connection = self.connection else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
guard let connection = self.connection else { return Data() }
|
||||
return try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainStore {
|
||||
static func loadString(service: String, account: String) -> String? {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||
let data = Data(value.utf8)
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
|
||||
let update: [String: Any] = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
|
||||
if status == errSecSuccess { return true }
|
||||
if status != errSecItemNotFound { return false }
|
||||
|
||||
var insert = base
|
||||
insert[kSecValueData as String] = data
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func delete(service: String, account: String) -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ClawdisNodeApp: App {
|
||||
@StateObject private var appModel = NodeAppModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootCanvas()
|
||||
.environmentObject(self.appModel)
|
||||
.environmentObject(self.appModel.voiceWake)
|
||||
.onOpenURL { url in
|
||||
Task { await self.appModel.handleDeepLink(url: url) }
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.appModel.setScenePhase(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Clawdis Node</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdis-bridge._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis Node needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdis Node uses on-device speech recognition for voice wake.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,235 @@
|
||||
import ClawdisKit
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class NodeAppModel: ObservableObject {
|
||||
@Published var isBackgrounded: Bool = false
|
||||
let screen = ScreenController()
|
||||
@Published var bridgeStatusText: String = "Not connected"
|
||||
@Published var bridgeServerName: String?
|
||||
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
|
||||
init() {
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
let nodeId = UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node"
|
||||
let sessionKey = "node-\(nodeId)"
|
||||
do {
|
||||
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .background:
|
||||
self.isBackgrounded = true
|
||||
case .active, .inactive:
|
||||
self.isBackgrounded = false
|
||||
@unknown default:
|
||||
self.isBackgrounded = false
|
||||
}
|
||||
}
|
||||
|
||||
func setVoiceWakeEnabled(_ enabled: Bool) {
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
token: String,
|
||||
nodeId: String,
|
||||
displayName: String?,
|
||||
platform: String,
|
||||
version: String)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeStatusText = "Connecting…"
|
||||
self.bridgeServerName = nil
|
||||
|
||||
self.bridgeTask = Task {
|
||||
do {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: BridgeHello(
|
||||
nodeId: nodeId,
|
||||
displayName: displayName,
|
||||
token: token,
|
||||
platform: platform,
|
||||
version: version),
|
||||
onConnected: { [weak self] serverName in
|
||||
await MainActor.run {
|
||||
self?.bridgeStatusText = "Connected"
|
||||
self?.bridgeServerName = serverName
|
||||
}
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
||||
}
|
||||
return await self.handleInvoke(req)
|
||||
})
|
||||
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Disconnected"
|
||||
self.bridgeServerName = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
self.bridgeServerName = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectBridge() {
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeTask = nil
|
||||
Task { await self.bridge.disconnect() }
|
||||
self.bridgeStatusText = "Disconnected"
|
||||
self.bridgeServerName = nil
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
struct Payload: Codable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
let payload = Payload(text: text, sessionKey: sessionKey)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
func handleDeepLink(url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
|
||||
if message.count > 20000 {
|
||||
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isBridgeConnected() else {
|
||||
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.screen.errorText = nil
|
||||
} catch {
|
||||
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAgentRequest(link: AgentDeepLink) async throws {
|
||||
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw NSError(domain: "DeepLink", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "invalid agent message",
|
||||
])
|
||||
}
|
||||
|
||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
||||
let data = try JSONEncoder().encode(link)
|
||||
let json = String(decoding: data, as: UTF8.self)
|
||||
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
private func isBridgeConnected() async -> Bool {
|
||||
if case .connected = await self.bridge.state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
if req.command.hasPrefix("screen."), self.isBackgrounded {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(
|
||||
code: .backgroundUnavailable,
|
||||
message: "NODE_BACKGROUND_UNAVAILABLE: screen commands require foreground"))
|
||||
}
|
||||
|
||||
do {
|
||||
switch req.command {
|
||||
case ClawdisScreenCommand.show.rawValue:
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
|
||||
case ClawdisScreenCommand.hide.rawValue:
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
|
||||
case ClawdisScreenCommand.setMode.rawValue:
|
||||
let params = try Self.decodeParams(ClawdisScreenSetModeParams.self, from: req.paramsJSON)
|
||||
self.screen.setMode(params.mode)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
|
||||
case ClawdisScreenCommand.navigate.rawValue:
|
||||
let params = try Self.decodeParams(ClawdisScreenNavigateParams.self, from: req.paramsJSON)
|
||||
self.screen.navigate(to: params.url)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
|
||||
case ClawdisScreenCommand.evalJS.rawValue:
|
||||
let params = try Self.decodeParams(ClawdisScreenEvalParams.self, from: req.paramsJSON)
|
||||
let result = try await self.screen.eval(javaScript: params.javaScript)
|
||||
let payload = try Self.encodePayload(["result": result])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case ClawdisScreenCommand.snapshot.rawValue:
|
||||
let params = try? Self.decodeParams(ClawdisScreenSnapshotParams.self, from: req.paramsJSON)
|
||||
let maxWidth = params?.maxWidth.map { CGFloat($0) }
|
||||
let base64 = try await self.screen.snapshotPNGBase64(maxWidth: maxWidth)
|
||||
let payload = try Self.encodePayload(["format": "png", "base64": base64])
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
default:
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
} catch {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: ClawdisNodeError(code: .unavailable, message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(obj)
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootCanvas: View {
|
||||
@State private var isShowingSettings = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ScreenTab()
|
||||
|
||||
Button {
|
||||
self.isShowingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(0.18),
|
||||
.white.opacity(0.04),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 10)
|
||||
.padding(.trailing, 10)
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
.sheet(isPresented: self.$isShowingSettings) {
|
||||
SettingsTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabs: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
ScreenTab()
|
||||
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
||||
|
||||
VoiceTab()
|
||||
.tabItem { Label("Voice", systemImage: "mic") }
|
||||
|
||||
SettingsTab()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import ClawdisKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class ScreenController: ObservableObject {
|
||||
let webView: WKWebView
|
||||
|
||||
@Published var mode: ClawdisScreenMode = .canvas
|
||||
@Published var urlString: String = ""
|
||||
@Published var errorText: String?
|
||||
|
||||
init() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
self.webView.scrollView.contentInset = .zero
|
||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||
self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func setMode(_ mode: ClawdisScreenMode) {
|
||||
self.mode = mode
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func navigate(to urlString: String) {
|
||||
self.urlString = urlString
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func reload() {
|
||||
switch self.mode {
|
||||
case .web:
|
||||
guard let url = URL(string: self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
|
||||
self.webView.load(URLRequest(url: url))
|
||||
case .canvas:
|
||||
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
private static let canvasScaffoldHTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
overflow: hidden;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px);
|
||||
transform: rotate(-7deg);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
canvas {
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#clawdis-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
#clawdis-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
}
|
||||
#clawdis-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="clawdis-canvas"></canvas>
|
||||
<div id="clawdis-status">
|
||||
<div class="card">
|
||||
<div class="title" id="clawdis-status-title">Ready</div>
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('clawdis-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
window.__clawdis = {
|
||||
canvas,
|
||||
ctx,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import ClawdisKit
|
||||
import SwiftUI
|
||||
|
||||
struct ScreenTab: View {
|
||||
@EnvironmentObject private var appModel: NodeAppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if let errorText = self.appModel.screen.errorText {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.padding(10)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation/mode selection is agent-driven; no local controls here.
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import ClawdisKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct ScreenWebView: UIViewRepresentable {
|
||||
@ObservedObject var controller: ScreenController
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
self.controller.webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// State changes are driven by ScreenController.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsTab: View {
|
||||
@EnvironmentObject private var appModel: NodeAppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@StateObject private var discovery = BridgeDiscoveryModel()
|
||||
@State private var connectStatus: String?
|
||||
@State private var isConnecting = false
|
||||
@State private var didAutoConnect = false
|
||||
@State private var isShowingBridgeList = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Node") {
|
||||
TextField("Name", text: self.$displayName)
|
||||
Text(self.instanceId)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Voice") {
|
||||
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Bridge") {
|
||||
LabeledContent("Discovery", value: self.discovery.statusText)
|
||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||
if let serverName = self.appModel.bridgeServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectBridge()
|
||||
}
|
||||
|
||||
DisclosureGroup("Switch bridge", isExpanded: self.$isShowingBridgeList) {
|
||||
self.bridgeList(showConnectedRow: true)
|
||||
}
|
||||
} else {
|
||||
self.bridgeList(showConnectedRow: false)
|
||||
}
|
||||
|
||||
if let connectStatus {
|
||||
Text(connectStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.onAppear { self.discovery.start() }
|
||||
.onDisappear { self.discovery.stop() }
|
||||
.onChange(of: self.discovery.bridges) { _, newValue in
|
||||
if self.didAutoConnect { return }
|
||||
if self.appModel.bridgeServerName != nil { return }
|
||||
|
||||
let existing = KeychainStore.loadString(
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount())
|
||||
guard let existing, !existing.isEmpty else { return }
|
||||
guard let first = newValue.first else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: first.endpoint,
|
||||
token: existing,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion())
|
||||
self.connectStatus = nil
|
||||
}
|
||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||
self.connectStatus = nil
|
||||
self.isShowingBridgeList = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bridgeList(showConnectedRow: Bool) -> some View {
|
||||
if self.discovery.bridges.isEmpty {
|
||||
Text("No bridges found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.discovery.bridges) { bridge in
|
||||
let isConnected = self.isConnectedBridge(bridge)
|
||||
if isConnected, !showConnectedRow {
|
||||
EmptyView()
|
||||
} else {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(bridge.name)
|
||||
Text(bridge.debugID)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if isConnected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Connected")
|
||||
} else {
|
||||
Button(self.isConnecting ? "…" : "Connect") {
|
||||
Task { await self.connect(bridge) }
|
||||
}
|
||||
.disabled(self.isConnecting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isConnectedBridge(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> Bool {
|
||||
guard let serverName = self.appModel.bridgeServerName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!serverName.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return bridge.name.localizedCaseInsensitiveContains(serverName)
|
||||
}
|
||||
|
||||
private func keychainAccount() -> String {
|
||||
"bridge-token.\(self.instanceId)"
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
|
||||
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
|
||||
do {
|
||||
let token: String
|
||||
if let existing, !existing.isEmpty {
|
||||
token = existing
|
||||
} else {
|
||||
let newToken = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion(),
|
||||
existingToken: nil)
|
||||
guard !newToken.isEmpty else {
|
||||
self.connectStatus = "Pairing failed: empty token"
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(
|
||||
newToken,
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount())
|
||||
token = newToken
|
||||
}
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
token: token,
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
platform: self.platformString(),
|
||||
version: self.appVersion())
|
||||
|
||||
self.connectStatus = "Connected"
|
||||
} catch {
|
||||
self.connectStatus = "Failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceTab: View {
|
||||
@EnvironmentObject private var appModel: NodeAppModel
|
||||
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Status") {
|
||||
LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled")
|
||||
LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle")
|
||||
Text(self.voiceWake.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Notes") {
|
||||
Text("Say “clawdis …” to trigger.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Voice")
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
enum SpeechAudioTapFactory {
|
||||
static func makeAppendTap(requestBox: SpeechRequestBox) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
{ buffer, _ in
|
||||
requestBox.append(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SpeechRequestBox: @unchecked Sendable {
|
||||
let request: SFSpeechAudioBufferRecognitionRequest
|
||||
|
||||
init(request: SFSpeechAudioBufferRecognitionRequest) {
|
||||
self.request = request
|
||||
}
|
||||
|
||||
func append(_ buffer: AVAudioPCMBuffer) {
|
||||
self.request.append(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class VoiceWakeManager: NSObject, ObservableObject {
|
||||
@Published var isEnabled: Bool = false
|
||||
@Published var isListening: Bool = false
|
||||
@Published var statusText: String = "Off"
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var speechRecognizer: SFSpeechRecognizer?
|
||||
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var recognitionTask: SFSpeechRecognitionTask?
|
||||
|
||||
private var lastDispatched: String?
|
||||
private var onCommand: (@Sendable (String) async -> Void)?
|
||||
|
||||
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
|
||||
self.onCommand = onCommand
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
Task { await self.start() }
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func start() async {
|
||||
guard self.isEnabled else { return }
|
||||
if self.isListening { return }
|
||||
|
||||
self.statusText = "Requesting permissions…"
|
||||
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.statusText = "Microphone permission denied"
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
let speechOk = await Self.requestSpeechPermission()
|
||||
guard speechOk else {
|
||||
self.statusText = "Speech recognition permission denied"
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
self.speechRecognizer = SFSpeechRecognizer()
|
||||
guard self.speechRecognizer != nil else {
|
||||
self.statusText = "Speech recognizer unavailable"
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try Self.configureAudioSession()
|
||||
try self.startRecognition()
|
||||
self.isListening = true
|
||||
self.statusText = "Listening"
|
||||
} catch {
|
||||
self.isListening = false
|
||||
self.statusText = "Start failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.statusText = "Off"
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func startRecognition() throws {
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
self.recognitionRequest = request
|
||||
|
||||
let inputNode = self.audioEngine.inputNode
|
||||
inputNode.removeTap(onBus: 0)
|
||||
|
||||
let requestBox = SpeechRequestBox(request: request)
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
let tap = SpeechAudioTapFactory.makeAppendTap(requestBox: requestBox)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat, block: tap)
|
||||
|
||||
self.audioEngine.prepare()
|
||||
try self.audioEngine.start()
|
||||
|
||||
let handler = self.makeRecognitionResultHandler()
|
||||
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
|
||||
}
|
||||
|
||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||
{ [weak self] result, error in
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
let errorText = error?.localizedDescription
|
||||
|
||||
Task { @MainActor in
|
||||
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
|
||||
if let errorText {
|
||||
self.statusText = "Recognizer error: \(errorText)"
|
||||
self.isListening = false
|
||||
|
||||
let shouldRestart = self.isEnabled
|
||||
if shouldRestart {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
await self.start()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let transcript else { return }
|
||||
guard let cmd = self.extractCommand(from: transcript) else { return }
|
||||
|
||||
if cmd == self.lastDispatched { return }
|
||||
self.lastDispatched = cmd
|
||||
self.statusText = "Triggered"
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.onCommand?(cmd)
|
||||
await self.startIfEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private func startIfEnabled() async {
|
||||
let shouldRestart = self.isEnabled
|
||||
if shouldRestart {
|
||||
await self.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func extractCommand(from transcript: String) -> String? {
|
||||
let lower = transcript.lowercased()
|
||||
guard let range = lower.range(of: "clawdis", options: .backwards) else { return nil }
|
||||
let after = lower[range.upperBound...]
|
||||
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .measurement, options: [
|
||||
.duckOthers,
|
||||
.mixWithOthers,
|
||||
.allowBluetoothHFP,
|
||||
.defaultToSpeaker,
|
||||
])
|
||||
try session.setActive(true, options: [])
|
||||
}
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
AVAudioApplication.requestRecordPermission { ok in
|
||||
Task { @MainActor in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
Task { @MainActor in
|
||||
cont.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
name: ClawdisNode
|
||||
options:
|
||||
bundleIdPrefix: com.steipete.clawdis
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
packages:
|
||||
ClawdisKit:
|
||||
path: ../shared/ClawdisKit
|
||||
|
||||
targets:
|
||||
ClawdisNode:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Sources
|
||||
dependencies:
|
||||
- package: ClawdisKit
|
||||
preBuildScripts:
|
||||
- name: SwiftFormat (lint)
|
||||
script: |
|
||||
set -euo pipefail
|
||||
if ! command -v swiftformat >/dev/null 2>&1; then
|
||||
echo "error: swiftformat not found (brew install swiftformat)" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftformat --lint --config "$SRCROOT/../../.swiftformat" \
|
||||
"$SRCROOT/Sources" \
|
||||
"$SRCROOT/../shared/ClawdisKit/Sources"
|
||||
- name: SwiftLint
|
||||
script: |
|
||||
set -euo pipefail
|
||||
if ! command -v swiftlint >/dev/null 2>&1; then
|
||||
echo "error: swiftlint not found (brew install swiftlint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml"
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.node
|
||||
SWIFT_VERSION: "6.0"
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdis Node
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
UIBackgroundModes:
|
||||
- audio
|
||||
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network.
|
||||
NSBonjourServices:
|
||||
- _clawdis-bridge._tcp
|
||||
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "clawdis-mac.png",
|
||||
"name" : "clawdis-mac",
|
||||
"position" : {
|
||||
"scale" : 1.07,
|
||||
"translation-in-points" : [
|
||||
-2,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orchetect/MenuBarExtraAccess",
|
||||
"state" : {
|
||||
"revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c",
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
|
||||
"version" : "2.8.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-subprocess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-subprocess.git",
|
||||
"state" : {
|
||||
"revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system",
|
||||
"state" : {
|
||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// swift-tools-version: 6.2
|
||||
// Package manifest for the Clawdis macOS companion (menu bar app + CLI + IPC library).
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Clawdis",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdisIPC", targets: ["ClawdisIPC"]),
|
||||
.executable(name: "Clawdis", targets: ["Clawdis"]),
|
||||
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(path: "../shared/ClawdisKit"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ClawdisProtocol",
|
||||
dependencies: [],
|
||||
path: "Sources/ClawdisProtocol",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdisIPC",
|
||||
dependencies: [],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "Clawdis",
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
"ClawdisProtocol",
|
||||
.product(name: "ClawdisKit", package: "ClawdisKit"),
|
||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources/Clawdis.icns"),
|
||||
.copy("Resources/WebChat"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdisCLI",
|
||||
dependencies: [
|
||||
"ClawdisIPC",
|
||||
"ClawdisProtocol",
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdisIPCTests",
|
||||
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
])
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettings: View {
|
||||
weak var updater: UpdaterProviding?
|
||||
@State private var iconHover = false
|
||||
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
|
||||
@State private var didLoadUpdaterState = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0)
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/steipete/clawdis") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.frame(width: 160, height: 160)
|
||||
.cornerRadius(24)
|
||||
.shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10)
|
||||
.scaleEffect(self.iconHover ? 1.05 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.onHover { hover in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
|
||||
}
|
||||
|
||||
VStack(spacing: 3) {
|
||||
Text("Clawdis")
|
||||
.font(.title3.bold())
|
||||
Text("Version \(self.versionString)")
|
||||
.foregroundStyle(.secondary)
|
||||
if let buildTimestamp {
|
||||
Text("Built \(buildTimestamp)\(self.buildSuffix)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
AboutLinkRow(
|
||||
icon: "chevron.left.slash.chevron.right",
|
||||
title: "GitHub",
|
||||
url: "https://github.com/steipete/clawdis")
|
||||
AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
|
||||
AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
|
||||
AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
if let updater {
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if updater.isAvailable {
|
||||
VStack(spacing: 10) {
|
||||
Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
} else {
|
||||
Text("Updates unavailable in this build.")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Text("© 2025 Peter Steinberger — MIT License.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
.onAppear {
|
||||
guard let updater, !self.didLoadUpdaterState else { return }
|
||||
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
|
||||
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
|
||||
self.didLoadUpdaterState = true
|
||||
}
|
||||
.onChange(of: self.autoCheckEnabled) { _, newValue in
|
||||
self.updater?.automaticallyChecksForUpdates = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var versionString: String {
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
|
||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
||||
return build.map { "\(version) (\($0))" } ?? version
|
||||
}
|
||||
|
||||
private var buildTimestamp: String? {
|
||||
guard let raw = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String else { return nil }
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
guard let date = parser.date(from: raw) else { return raw }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private var gitCommit: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "ClawdisGitCommit") as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private var bundleID: String {
|
||||
Bundle.main.bundleIdentifier ?? "unknown"
|
||||
}
|
||||
|
||||
private var buildSuffix: String {
|
||||
let git = self.gitCommit
|
||||
guard !git.isEmpty, git != "unknown" else { return "" }
|
||||
|
||||
var suffix = " (\(git)"
|
||||
#if DEBUG
|
||||
suffix += " DEBUG"
|
||||
#endif
|
||||
suffix += ")"
|
||||
return suffix
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct AboutLinkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let url: String
|
||||
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if let url = URL(string: url) { NSWorkspace.shared.open(url) }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: self.icon)
|
||||
Text(self.title)
|
||||
.underline(self.hovering, color: .accentColor)
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { self.hovering = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private struct AboutMetaRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(self.label)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(self.value)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct AboutSettings_Previews: PreviewProvider {
|
||||
private static let updater = DisabledUpdaterController()
|
||||
static var previews: some View {
|
||||
AboutSettings(updater: updater)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AgentEventStore: ObservableObject {
|
||||
static let shared = AgentEventStore()
|
||||
|
||||
@Published private(set) var events: [ControlAgentEvent] = []
|
||||
private let maxEvents = 400
|
||||
|
||||
func append(_ event: ControlAgentEvent) {
|
||||
self.events.append(event)
|
||||
if self.events.count > self.maxEvents {
|
||||
self.events.removeFirst(self.events.count - self.maxEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
self.events.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AgentEventsWindow: View {
|
||||
@ObservedObject private var store = AgentEventStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Agent Events")
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
Button("Clear") { self.store.clear() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.store.events.reversed(), id: \.seq) { evt in
|
||||
EventRow(event: evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minWidth: 520, minHeight: 360)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EventRow: View {
|
||||
let event: ControlAgentEvent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.event.stream.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(self.tint)
|
||||
.foregroundStyle(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
Text("run " + self.event.runId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(self.formattedTs)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let json = self.prettyJSON(event.data) {
|
||||
Text(json)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.04)))
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch self.event.stream {
|
||||
case "job": .blue
|
||||
case "tool": .orange
|
||||
case "assistant": .green
|
||||
default: .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedTs: String {
|
||||
let date = Date(timeIntervalSince1970: event.ts / 1000)
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
||||
let normalized = dict.mapValues { $0.value }
|
||||
guard JSONSerialization.isValidJSONObject(normalized),
|
||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentEventsWindow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sample = ControlAgentEvent(
|
||||
runId: "abc",
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
return AgentEventsWindow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct ControlRequestParams: @unchecked Sendable {
|
||||
/// Heterogeneous JSON-ish params (Bool/String/Int/Double/[...]/[String:...]).
|
||||
/// `@unchecked Sendable` is intentional: values are treated as immutable payloads.
|
||||
let raw: [String: Any]
|
||||
}
|
||||
|
||||
actor AgentRPC {
|
||||
static let shared = AgentRPC()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "agent.rpc")
|
||||
|
||||
func shutdown() async {
|
||||
// no-op; socket managed by GatewayConnection
|
||||
}
|
||||
|
||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||
do {
|
||||
_ = try await self.controlRequest(
|
||||
method: "set-heartbeats",
|
||||
params: ControlRequestParams(raw: ["enabled": enabled]))
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func status() async -> (ok: Bool, error: String?) {
|
||||
do {
|
||||
let data = try await controlRequest(method: "status")
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["ok"] as? Bool) ?? true
|
||||
{
|
||||
return (true, nil)
|
||||
}
|
||||
return (false, "status error")
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func send(
|
||||
text: String,
|
||||
thinking: String?,
|
||||
sessionKey: String,
|
||||
deliver: Bool,
|
||||
to: String?,
|
||||
channel: String? = nil) async -> (ok: Bool, text: String?, error: String?)
|
||||
{
|
||||
do {
|
||||
let params: [String: Any] = [
|
||||
"message": text,
|
||||
"sessionKey": sessionKey,
|
||||
"thinking": thinking ?? "default",
|
||||
"deliver": deliver,
|
||||
"to": to ?? "",
|
||||
"channel": channel ?? "",
|
||||
"idempotencyKey": UUID().uuidString,
|
||||
]
|
||||
_ = try await self.controlRequest(method: "agent", params: ControlRequestParams(raw: params))
|
||||
return (true, nil, nil)
|
||||
} catch {
|
||||
return (false, nil, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func controlRequest(method: String, params: ControlRequestParams? = nil) async throws -> Data {
|
||||
let rawParams = params?.raw.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
return try await GatewayConnection.shared.request(method: method, params: rawParams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as NSDictionary:
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for (k, v) in dict {
|
||||
guard let key = k as? String else { continue }
|
||||
converted[key] = AnyCodable(v)
|
||||
}
|
||||
try container.encode(converted)
|
||||
case let array as NSArray:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
default:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
private let isPreview: Bool
|
||||
|
||||
private func ifNotPreview(_ action: () -> Void) {
|
||||
guard !self.isPreview else { return }
|
||||
action()
|
||||
}
|
||||
|
||||
enum ConnectionMode: String {
|
||||
case local
|
||||
case remote
|
||||
}
|
||||
|
||||
@Published var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
|
||||
@Published var launchAtLogin: Bool {
|
||||
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
|
||||
}
|
||||
|
||||
@Published var onboardingSeen: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
|
||||
}
|
||||
}
|
||||
|
||||
@Published var debugPaneEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
|
||||
}
|
||||
}
|
||||
|
||||
@Published var swabbleEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var swabbleTriggerWords: [String] {
|
||||
didSet {
|
||||
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var voiceWakeTriggerChime: VoiceWakeChime {
|
||||
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
|
||||
}
|
||||
|
||||
@Published var voiceWakeSendChime: VoiceWakeChime {
|
||||
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
|
||||
}
|
||||
|
||||
@Published var iconAnimationsEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
self.iconAnimationsEnabled,
|
||||
forKey: iconAnimationsEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var showDockIcon: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
||||
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var voiceWakeMicID: String {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var voiceWakeLocaleID: String {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var voiceWakeAdditionalLocaleIDs: [String] {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
self.voiceWakeAdditionalLocaleIDs,
|
||||
forKey: voiceWakeAdditionalLocalesKey) } }
|
||||
}
|
||||
|
||||
@Published var voicePushToTalkEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
self.voicePushToTalkEnabled,
|
||||
forKey: voicePushToTalkEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var iconOverride: IconOverrideSelection {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||
}
|
||||
|
||||
@Published var isWorking: Bool = false
|
||||
@Published var earBoostActive: Bool = false
|
||||
@Published var blinkTick: Int = 0
|
||||
@Published var sendCelebrationTick: Int = 0
|
||||
@Published var heartbeatsEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
||||
Task { _ = await AgentRPC.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var connectionMode: ConnectionMode {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
}
|
||||
}
|
||||
|
||||
@Published var webChatEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var webChatSwiftUIEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var webChatPort: Int {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
|
||||
}
|
||||
|
||||
@Published var canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
@Published var attachExistingGatewayOnly: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var remoteTarget: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
|
||||
}
|
||||
|
||||
@Published var remoteIdentity: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
|
||||
@Published var remoteProjectRoot: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
||||
}
|
||||
|
||||
private var earBoostTask: Task<Void, Never>?
|
||||
|
||||
init(preview: Bool = false) {
|
||||
self.isPreview = preview
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
|
||||
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
|
||||
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
|
||||
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
|
||||
self.swabbleTriggerWords = UserDefaults.standard
|
||||
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
|
||||
self.voiceWakeTriggerChime = Self.loadChime(
|
||||
key: voiceWakeTriggerChimeKey,
|
||||
fallback: .system(name: "Glass"))
|
||||
self.voiceWakeSendChime = Self.loadChime(
|
||||
key: voiceWakeSendChimeKey,
|
||||
fallback: .system(name: "Glass"))
|
||||
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
|
||||
self.iconAnimationsEnabled = storedIconAnimations
|
||||
} else {
|
||||
self.iconAnimationsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
|
||||
}
|
||||
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||
self.voicePushToTalkEnabled = UserDefaults.standard
|
||||
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||
self.heartbeatsEnabled = storedHeartbeats
|
||||
} else {
|
||||
self.heartbeatsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||
}
|
||||
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
||||
let selection = IconOverrideSelection(rawValue: storedOverride)
|
||||
{
|
||||
self.iconOverride = selection
|
||||
} else {
|
||||
self.iconOverride = .system
|
||||
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||
}
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
|
||||
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.webChatEnabled = UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
|
||||
self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false
|
||||
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
|
||||
self.webChatPort = storedPort > 0 ? storedPort : 18788
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
|
||||
if !self.isPreview {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
let current = await LaunchAgentManager.status()
|
||||
await MainActor.run { [weak self] in self?.launchAtLogin = current }
|
||||
}
|
||||
}
|
||||
|
||||
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||
self.swabbleEnabled = false
|
||||
}
|
||||
|
||||
if !self.isPreview {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
|
||||
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||
self.earBoostTask?.cancel()
|
||||
self.earBoostActive = true
|
||||
|
||||
guard let ttl else { return }
|
||||
|
||||
self.earBoostTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000))
|
||||
await MainActor.run { [weak self] in self?.earBoostActive = false }
|
||||
}
|
||||
}
|
||||
|
||||
func stopVoiceEars() {
|
||||
self.earBoostTask?.cancel()
|
||||
self.earBoostTask = nil
|
||||
self.earBoostActive = false
|
||||
}
|
||||
|
||||
func blinkOnce() {
|
||||
self.blinkTick &+= 1
|
||||
}
|
||||
|
||||
func celebrateSend() {
|
||||
self.sendCelebrationTick &+= 1
|
||||
}
|
||||
|
||||
func setVoiceWakeEnabled(_ enabled: Bool) async {
|
||||
guard voiceWakeSupported else {
|
||||
self.swabbleEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
self.swabbleEnabled = enabled
|
||||
guard !self.isPreview else { return }
|
||||
|
||||
if !enabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
return
|
||||
}
|
||||
|
||||
if PermissionManager.voiceWakePermissionsGranted() {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
return
|
||||
}
|
||||
|
||||
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
|
||||
self.swabbleEnabled = granted
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
|
||||
func setWorking(_ working: Bool) {
|
||||
self.isWorking = working
|
||||
}
|
||||
|
||||
// MARK: - Chime persistence
|
||||
|
||||
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
|
||||
guard let data = UserDefaults.standard.data(forKey: key) else { return fallback }
|
||||
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
|
||||
return decoded
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func storeChime(_ chime: VoiceWakeChime, key: String) {
|
||||
guard let data = try? JSONEncoder().encode(chime) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppState {
|
||||
static var preview: AppState {
|
||||
let state = AppState(preview: true)
|
||||
state.isPaused = false
|
||||
state.launchAtLogin = true
|
||||
state.onboardingSeen = true
|
||||
state.debugPaneEnabled = true
|
||||
state.swabbleEnabled = true
|
||||
state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"]
|
||||
state.voiceWakeTriggerChime = .system(name: "Glass")
|
||||
state.voiceWakeSendChime = .system(name: "Ping")
|
||||
state.iconAnimationsEnabled = true
|
||||
state.showDockIcon = true
|
||||
state.voiceWakeMicID = "BuiltInMic"
|
||||
state.voiceWakeLocaleID = Locale.current.identifier
|
||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||
state.voicePushToTalkEnabled = false
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.webChatEnabled = true
|
||||
state.webChatSwiftUIEnabled = false
|
||||
state.webChatPort = 18788
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdis"
|
||||
state.attachExistingGatewayOnly = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum AppStateStore {
|
||||
static let shared = AppState()
|
||||
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
|
||||
|
||||
static func updateLaunchAtLogin(enabled: Bool) {
|
||||
Task.detached(priority: .utility) {
|
||||
await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
|
||||
}
|
||||
}
|
||||
|
||||
static var webChatEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static var webChatSwiftUIEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
static var webChatPort: Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: webChatPortKey)
|
||||
return stored > 0 ? stored : 18788
|
||||
}
|
||||
|
||||
static var canvasEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum AppActivationPolicy {
|
||||
static func apply(showDockIcon: Bool) {
|
||||
NSApp.setActivationPolicy(showDockIcon ? .regular : .accessory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor BridgeConnectionHandler {
|
||||
private let connection: NWConnection
|
||||
private let logger: Logger
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let queue = DispatchQueue(label: "com.steipete.clawdis.bridge.connection")
|
||||
|
||||
private var buffer = Data()
|
||||
private var isAuthenticated = false
|
||||
private var nodeId: String?
|
||||
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
|
||||
private var isClosed = false
|
||||
|
||||
init(connection: NWConnection, logger: Logger) {
|
||||
self.connection = connection
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
enum AuthResult: Sendable {
|
||||
case ok
|
||||
case notPaired
|
||||
case unauthorized
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
enum PairResult: Sendable {
|
||||
case ok(token: String)
|
||||
case rejected
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
func run(
|
||||
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
||||
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
||||
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async
|
||||
{
|
||||
self.connection.stateUpdateHandler = { [logger] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
logger.debug("bridge conn ready")
|
||||
case let .failed(err):
|
||||
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
self.connection.start(queue: self.queue)
|
||||
|
||||
while true {
|
||||
do {
|
||||
guard let line = try await self.receiveLine() else { break }
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
|
||||
switch base.type {
|
||||
case "hello":
|
||||
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
||||
self.nodeId = hello.nodeId
|
||||
let result = await resolveAuth(hello)
|
||||
await self.handleAuthResult(
|
||||
result,
|
||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
|
||||
if case .ok = result, let nodeId = self.nodeId {
|
||||
await onAuthenticated?(nodeId)
|
||||
}
|
||||
case "pair-request":
|
||||
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
|
||||
self.nodeId = req.nodeId
|
||||
let enriched = BridgePairRequest(
|
||||
type: req.type,
|
||||
nodeId: req.nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
remoteAddress: self.remoteAddressString())
|
||||
let result = await handlePair(enriched)
|
||||
await self.handlePairResult(
|
||||
result,
|
||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName)
|
||||
if case .ok = result, let nodeId = self.nodeId {
|
||||
await onAuthenticated?(nodeId)
|
||||
}
|
||||
case "event":
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
continue
|
||||
}
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
|
||||
await onEvent?(nodeId, evt)
|
||||
case "ping":
|
||||
if !self.isAuthenticated {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
continue
|
||||
}
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: data)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
case "invoke-res":
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
continue
|
||||
}
|
||||
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
|
||||
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
default:
|
||||
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
await self.close(with: onDisconnected)
|
||||
}
|
||||
|
||||
private func remoteAddressString() -> String? {
|
||||
switch self.connection.endpoint {
|
||||
case let .hostPort(host: host, port: _):
|
||||
let value = String(describing: host)
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func remoteAddress() -> String? {
|
||||
self.remoteAddressString()
|
||||
}
|
||||
|
||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||
switch result {
|
||||
case let .ok(token):
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .rejected:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
|
||||
switch result {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .notPaired:
|
||||
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
|
||||
case .unauthorized:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendError(code: String, message: String) async {
|
||||
do {
|
||||
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
|
||||
} catch {
|
||||
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard self.isAuthenticated else {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
|
||||
])
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
|
||||
await self.timeoutInvoke(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginInvoke(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginInvoke(
|
||||
id: String,
|
||||
request: BridgeInvokeRequest,
|
||||
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
|
||||
{
|
||||
self.pendingInvokes[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failInvoke(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutInvoke(id: String) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
|
||||
]))
|
||||
}
|
||||
|
||||
private func failInvoke(id: String, error: Error) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // \n
|
||||
let _: Void = try await withCheckedThrowingContinuation { cont in
|
||||
self.connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err {
|
||||
cont.resume(throwing: err)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connection
|
||||
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
|
||||
if self.isClosed { return }
|
||||
self.isClosed = true
|
||||
|
||||
let nodeId = self.nodeId
|
||||
let pending = self.pendingInvokes.values
|
||||
self.pendingInvokes.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
self.connection.cancel()
|
||||
if let nodeId {
|
||||
await onDisconnected?(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import AppKit
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor BridgeServer {
|
||||
static let shared = BridgeServer()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "bridge")
|
||||
private var listener: NWListener?
|
||||
private var isRunning = false
|
||||
private var store: PairedNodesStore?
|
||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
func start() async {
|
||||
if self.isRunning { return }
|
||||
self.isRunning = true
|
||||
|
||||
do {
|
||||
let storeURL = try Self.defaultStoreURL()
|
||||
let store = PairedNodesStore(fileURL: storeURL)
|
||||
await store.load()
|
||||
self.store = store
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
|
||||
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
listener.service = NWListener.Service(
|
||||
name: "\(name) (Clawdis)",
|
||||
type: ClawdisBonjour.bridgeServiceType,
|
||||
domain: ClawdisBonjour.bridgeServiceDomain,
|
||||
txtRecord: nil)
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
guard let self else { return }
|
||||
Task { await self.handle(connection: connection) }
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleListenerState(state) }
|
||||
}
|
||||
|
||||
listener.start(queue: DispatchQueue(label: "com.steipete.clawdis.bridge"))
|
||||
self.listener = listener
|
||||
} catch {
|
||||
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
self.isRunning = false
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
private func handleListenerState(_ state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
self.logger.info("bridge listening")
|
||||
case let .failed(err):
|
||||
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
|
||||
case .cancelled:
|
||||
self.logger.info("bridge listener cancelled")
|
||||
case .waiting:
|
||||
self.logger.info("bridge listener waiting")
|
||||
case .setup:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(connection: NWConnection) async {
|
||||
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
|
||||
await handler.run(
|
||||
resolveAuth: { [weak self] hello in
|
||||
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
handlePair: { [weak self] request in
|
||||
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
onAuthenticated: { [weak self] nodeId in
|
||||
await self?.registerConnection(handler: handler, nodeId: nodeId)
|
||||
},
|
||||
onDisconnected: { [weak self] nodeId in
|
||||
await self?.unregisterConnection(nodeId: nodeId)
|
||||
},
|
||||
onEvent: { [weak self] nodeId, evt in
|
||||
await self?.handleEvent(nodeId: nodeId, evt: evt)
|
||||
})
|
||||
}
|
||||
|
||||
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = self.connections[nodeId] else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
|
||||
])
|
||||
}
|
||||
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
|
||||
}
|
||||
|
||||
func connectedNodeIds() -> [String] {
|
||||
Array(self.connections.keys).sorted()
|
||||
}
|
||||
|
||||
private func registerConnection(handler: BridgeConnectionHandler, nodeId: String) async {
|
||||
self.connections[nodeId] = handler
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "connect")
|
||||
self.startPresenceTask(nodeId: nodeId)
|
||||
}
|
||||
|
||||
private func unregisterConnection(nodeId: String) async {
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||
self.stopPresenceTask(nodeId: nodeId)
|
||||
self.connections.removeValue(forKey: nodeId)
|
||||
}
|
||||
|
||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
||||
switch evt.event {
|
||||
case "voice.transcript":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
|
||||
return
|
||||
}
|
||||
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
|
||||
_ = await AgentRPC.shared.send(
|
||||
text: text,
|
||||
thinking: "low",
|
||||
sessionKey: sessionKey,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: "last")
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
guard message.count <= 20000 else { return }
|
||||
|
||||
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
|
||||
_ = await AgentRPC.shared.send(
|
||||
text: message,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel ?? "last")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func beaconPresence(nodeId: String, reason: String) async {
|
||||
do {
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: Any] = [
|
||||
"text": summary,
|
||||
"instanceId": nodeId,
|
||||
"host": host,
|
||||
"mode": "node",
|
||||
"reason": reason,
|
||||
"tags": tags,
|
||||
]
|
||||
if let ip { params["ip"] = ip }
|
||||
if let version { params["version"] = version }
|
||||
_ = try await AgentRPC.shared.controlRequest(
|
||||
method: "system-event",
|
||||
params: ControlRequestParams(raw: params))
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private func startPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks[nodeId] = Task.detached { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks.removeValue(forKey: nodeId)
|
||||
}
|
||||
|
||||
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
guard let paired = await store.find(nodeId: nodeId) else {
|
||||
return .notPaired
|
||||
}
|
||||
guard let token = hello.token, token == paired.token else {
|
||||
return .unauthorized
|
||||
}
|
||||
do { try await store.touchSeen(nodeId: nodeId) } catch { /* ignore */ }
|
||||
return .ok
|
||||
}
|
||||
|
||||
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
|
||||
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
let existing = await store.find(nodeId: nodeId)
|
||||
|
||||
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
|
||||
if !approved {
|
||||
return .rejected
|
||||
}
|
||||
|
||||
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let node = PairedNode(
|
||||
nodeId: nodeId,
|
||||
displayName: request.displayName,
|
||||
platform: request.platform,
|
||||
version: request.version,
|
||||
token: token,
|
||||
createdAtMs: nowMs,
|
||||
lastSeenAtMs: nowMs)
|
||||
do {
|
||||
try await store.upsert(node)
|
||||
return .ok(token: token)
|
||||
} catch {
|
||||
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultStoreURL() throws -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(
|
||||
domain: "Bridge",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
|
||||
}
|
||||
return base
|
||||
.appendingPathComponent("Clawdis", isDirectory: true)
|
||||
.appendingPathComponent("bridge", isDirectory: true)
|
||||
.appendingPathComponent("paired-nodes.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BridgePairingApprover {
|
||||
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
let name = request.displayName ?? request.nodeId
|
||||
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let alert = NSAlert()
|
||||
alert.messageText = isRepair ? "Re-pair Clawdis Node?" : "Pair Clawdis Node?"
|
||||
alert.informativeText = """
|
||||
Node: \(name)
|
||||
IP: \(remote ?? "unknown")
|
||||
Platform: \(request.platform ?? "unknown")
|
||||
Version: \(request.version ?? "unknown")
|
||||
"""
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
let resp = alert.runModal()
|
||||
cont.resume(returning: resp == .alertFirstButtonReturn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
fileprivate var nonEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct PairedNode: Codable, Equatable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var token: String
|
||||
var createdAtMs: Int
|
||||
var lastSeenAtMs: Int?
|
||||
}
|
||||
|
||||
actor PairedNodesStore {
|
||||
private let fileURL: URL
|
||||
private var nodes: [String: PairedNode] = [:]
|
||||
|
||||
init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
let data = try Data(contentsOf: self.fileURL)
|
||||
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
|
||||
self.nodes = decoded
|
||||
} catch {
|
||||
self.nodes = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func all() -> [PairedNode] {
|
||||
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
|
||||
}
|
||||
|
||||
func find(nodeId: String) -> PairedNode? {
|
||||
self.nodes[nodeId]
|
||||
}
|
||||
|
||||
func upsert(_ node: PairedNode) async throws {
|
||||
self.nodes[node.nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
func touchSeen(nodeId: String) async throws {
|
||||
guard var node = self.nodes[nodeId] else { return }
|
||||
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.nodes[nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
private func persist() async throws {
|
||||
let dir = self.fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(self.nodes)
|
||||
try data.write(to: self.fileURL, options: [.atomic])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher")
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.url.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class CanvasManager {
|
||||
static let shared = CanvasManager()
|
||||
|
||||
private var panelController: CanvasWindowController?
|
||||
private var panelSessionKey: String?
|
||||
|
||||
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
|
||||
var defaultAnchorProvider: (() -> NSRect?)?
|
||||
|
||||
private nonisolated static let canvasRoot: URL = {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdis/canvas", isDirectory: true)
|
||||
}()
|
||||
|
||||
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
|
||||
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let controller = self.panelController, self.panelSessionKey == session {
|
||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
controller.applyPreferredPlacement(placement)
|
||||
controller.goto(path: path ?? "/")
|
||||
return controller.directoryPath
|
||||
}
|
||||
|
||||
self.panelController?.close()
|
||||
self.panelController = nil
|
||||
self.panelSessionKey = nil
|
||||
|
||||
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: session,
|
||||
root: Self.canvasRoot,
|
||||
presentation: .panel(anchorProvider: anchorProvider))
|
||||
self.panelController = controller
|
||||
self.panelSessionKey = session
|
||||
controller.applyPreferredPlacement(placement)
|
||||
controller.showCanvas(path: path ?? "/")
|
||||
return controller.directoryPath
|
||||
}
|
||||
|
||||
func hide(sessionKey: String) {
|
||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.panelSessionKey == session else { return }
|
||||
self.panelController?.hideCanvas()
|
||||
}
|
||||
|
||||
func hideAll() {
|
||||
self.panelController?.hideCanvas()
|
||||
}
|
||||
|
||||
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
|
||||
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
|
||||
}
|
||||
|
||||
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
||||
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||
guard let controller = self.panelController else { return "" }
|
||||
return await controller.eval(javaScript: javaScript)
|
||||
}
|
||||
|
||||
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
|
||||
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||
guard let controller = self.panelController else {
|
||||
throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"])
|
||||
}
|
||||
return try await controller.snapshot(to: outPath)
|
||||
}
|
||||
|
||||
// MARK: - Anchoring
|
||||
|
||||
private static func mouseAnchorProvider() -> NSRect? {
|
||||
let pt = NSEvent.mouseLocation
|
||||
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
|
||||
}
|
||||
|
||||
// placement interpretation is handled by the window controller.
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum CanvasScheme {
|
||||
static let scheme = "clawdis-canvas"
|
||||
|
||||
static func makeURL(session: String, path: String? = nil) -> URL? {
|
||||
var comps = URLComponents()
|
||||
comps.scheme = Self.scheme
|
||||
comps.host = session
|
||||
let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if p.isEmpty || p == "/" {
|
||||
comps.path = "/"
|
||||
} else if p.hasPrefix("/") {
|
||||
comps.path = p
|
||||
} else {
|
||||
comps.path = "/" + p
|
||||
}
|
||||
return comps.url
|
||||
}
|
||||
|
||||
static func mimeType(forExtension ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
// Note: WKURLSchemeHandler uses URLResponse(mimeType:), which expects a bare MIME type
|
||||
// (no `; charset=...`). Encoding is provided via URLResponse(textEncodingName:).
|
||||
case "html", "htm": "text/html"
|
||||
case "js", "mjs": "application/javascript"
|
||||
case "css": "text/css"
|
||||
case "json", "map": "application/json"
|
||||
case "svg": "image/svg+xml"
|
||||
case "png": "image/png"
|
||||
case "jpg", "jpeg": "image/jpeg"
|
||||
case "gif": "image/gif"
|
||||
case "ico": "image/x-icon"
|
||||
case "woff2": "font/woff2"
|
||||
case "woff": "font/woff"
|
||||
case "ttf": "font/ttf"
|
||||
case "wasm": "application/wasm"
|
||||
default: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
||||
|
||||
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let root: URL
|
||||
|
||||
init(root: URL) {
|
||||
self.root = root
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
guard let url = urlSchemeTask.request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "missing url",
|
||||
]))
|
||||
return
|
||||
}
|
||||
|
||||
let response = self.response(for: url)
|
||||
let mime = response.mime
|
||||
let data = response.data
|
||||
let encoding = self.textEncodingName(forMimeType: mime)
|
||||
|
||||
let urlResponse = URLResponse(
|
||||
url: url,
|
||||
mimeType: mime,
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: encoding)
|
||||
urlSchemeTask.didReceive(urlResponse)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, stop _: WKURLSchemeTask) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private struct CanvasResponse {
|
||||
let mime: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
private func response(for url: URL) -> CanvasResponse {
|
||||
guard url.scheme == CanvasScheme.scheme else {
|
||||
return self.html("Invalid scheme.")
|
||||
}
|
||||
guard let session = url.host, !session.isEmpty else {
|
||||
return self.html("Missing session.")
|
||||
}
|
||||
|
||||
// Keep session component safe; don't allow slashes or traversal.
|
||||
if session.contains("/") || session.contains("..") {
|
||||
return self.html("Invalid session.")
|
||||
}
|
||||
|
||||
let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true)
|
||||
|
||||
// Path mapping: request path maps directly into the session dir.
|
||||
var path = url.path
|
||||
if let qIdx = path.firstIndex(of: "?") { path = String(path[..<qIdx]) }
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
// Special-case: welcome page when root index is missing.
|
||||
if path.isEmpty {
|
||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
{
|
||||
return self.welcomePage(sessionRoot: sessionRoot)
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
|
||||
guard let fileURL = resolved else {
|
||||
return self.html("Not Found", title: "Canvas: 404")
|
||||
}
|
||||
|
||||
// Directory traversal guard: served files must live under the session root.
|
||||
let standardizedRoot = sessionRoot.standardizedFileURL
|
||||
let standardizedFile = fileURL.standardizedFileURL
|
||||
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: standardizedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
} catch {
|
||||
canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return self.html("Failed to read file.", title: "Canvas error")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||
let fm = FileManager.default
|
||||
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
|
||||
if isDir.boolValue {
|
||||
if let idx = self.resolveIndex(in: candidate) { return idx }
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// Directory index behavior:
|
||||
// - "/yolo" serves "<yolo>/index.html" if that directory exists.
|
||||
if !requestPath.isEmpty, !requestPath.hasSuffix("/") {
|
||||
candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true)
|
||||
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
|
||||
if let idx = self.resolveIndex(in: candidate) { return idx }
|
||||
}
|
||||
}
|
||||
|
||||
// Root fallback:
|
||||
// - "/" serves "<sessionRoot>/index.html" if present.
|
||||
if requestPath.isEmpty {
|
||||
return self.resolveIndex(in: sessionRoot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveIndex(in dir: URL) -> URL? {
|
||||
let fm = FileManager.default
|
||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) { return a }
|
||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if fm.fileExists(atPath: b.path) { return b }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>\(title)</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background: #fff;
|
||||
color:#111827;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
padding: 18px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.08);
|
||||
}
|
||||
.muted { color:#6b7280; margin-top:8px; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>\(body)</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return CanvasResponse(mime: "text/html", data: Data(html.utf8))
|
||||
}
|
||||
|
||||
private func welcomePage(sessionRoot: URL) -> CanvasResponse {
|
||||
let escaped = sessionRoot.path
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
let body = """
|
||||
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
|
||||
<div class="muted">Create <code>index.html</code> in:</div>
|
||||
<div style="margin-top:10px;"><code>\(escaped)</code></div>
|
||||
"""
|
||||
return self.html(body, title: "Canvas")
|
||||
}
|
||||
|
||||
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||
if mimeType.hasPrefix("text/") { return "utf-8" }
|
||||
switch mimeType {
|
||||
case "application/javascript", "application/json", "image/svg+xml":
|
||||
return "utf-8"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
import QuartzCore
|
||||
|
||||
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
||||
|
||||
private enum CanvasLayout {
|
||||
static let panelSize = NSSize(width: 520, height: 680)
|
||||
static let windowSize = NSSize(width: 1120, height: 840)
|
||||
static let anchorPadding: CGFloat = 8
|
||||
static let defaultPadding: CGFloat = 10
|
||||
static let minPanelSize = NSSize(width: 360, height: 360)
|
||||
}
|
||||
|
||||
final class CanvasPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
}
|
||||
|
||||
enum CanvasPresentation {
|
||||
case window
|
||||
case panel(anchorProvider: () -> NSRect?)
|
||||
|
||||
var isPanel: Bool {
|
||||
if case .panel = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
private let sessionKey: String
|
||||
private let root: URL
|
||||
private let sessionDir: URL
|
||||
private let schemeHandler: CanvasSchemeHandler
|
||||
private let webView: WKWebView
|
||||
private let watcher: CanvasFileWatcher
|
||||
private let container: HoverChromeContainerView
|
||||
let presentation: CanvasPresentation
|
||||
private var preferredPlacement: CanvasPlacement?
|
||||
|
||||
var onVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
|
||||
self.sessionKey = sessionKey
|
||||
self.root = root
|
||||
self.presentation = presentation
|
||||
|
||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
|
||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController = WKUserContentController()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.setValue(false, forKey: "drawsBackground")
|
||||
|
||||
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
|
||||
Task { @MainActor in
|
||||
// Only auto-reload when we are showing local canvas content.
|
||||
guard webView?.url?.scheme == CanvasScheme.scheme else { return }
|
||||
webView?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
self.container = HoverChromeContainerView(containing: self.webView)
|
||||
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
||||
super.init(window: window)
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.window?.delegate = self
|
||||
self.container.onClose = { [weak self] in
|
||||
self?.hideCanvas()
|
||||
}
|
||||
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
@MainActor deinit {
|
||||
self.watcher.stop()
|
||||
}
|
||||
|
||||
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
|
||||
self.preferredPlacement = placement
|
||||
}
|
||||
|
||||
func showCanvas(path: String? = nil) {
|
||||
if case .panel(let anchorProvider) = self.presentation {
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.showWindow(nil)
|
||||
self.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
func hideCanvas() {
|
||||
if case .panel = self.presentation {
|
||||
self.persistFrameIfPanel()
|
||||
self.window?.orderOut(nil)
|
||||
} else {
|
||||
self.close()
|
||||
}
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func goto(path: String) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" {
|
||||
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else {
|
||||
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
|
||||
return
|
||||
}
|
||||
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async -> String {
|
||||
await withCheckedContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(returning: "error: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(to outPath: String?) async throws -> String {
|
||||
let image: NSImage = try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.takeSnapshot(with: nil) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot returned nil image",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw NSError(domain: "Canvas", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "failed to encode png",
|
||||
])
|
||||
}
|
||||
|
||||
let path: String
|
||||
if let outPath, !outPath.isEmpty {
|
||||
path = outPath
|
||||
} else {
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
|
||||
return path
|
||||
}
|
||||
|
||||
var directoryPath: String {
|
||||
self.sessionDir.path
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
switch presentation {
|
||||
case .window:
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis Canvas"
|
||||
window.contentView = contentView
|
||||
window.center()
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
return window
|
||||
|
||||
case .panel:
|
||||
let panel = CanvasPanel(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
||||
styleMask: [.borderless, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
// Keep Canvas below the Voice Wake overlay panel.
|
||||
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
|
||||
panel.hasShadow = true
|
||||
panel.isMovable = false
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
panel.backgroundColor = .clear
|
||||
panel.isOpaque = false
|
||||
panel.contentView = contentView
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.minSize = CanvasLayout.minPanelSize
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
self.repositionPanel(using: anchorProvider)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeFirstResponder(self.webView)
|
||||
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||
guard let panel = self.window else { return }
|
||||
let anchor = anchorProvider()
|
||||
let screen = NSScreen.screens.first { screen in
|
||||
guard let anchor else { return false }
|
||||
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
||||
} ?? NSScreen.main
|
||||
|
||||
// Base frame: restored frame (preferred), otherwise default top-right.
|
||||
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(panel: panel, screen: screen)
|
||||
|
||||
// Apply agent placement as partial overrides:
|
||||
// - If agent provides x/y, override origin.
|
||||
// - If agent provides width/height, override size.
|
||||
// - If agent provides only size, keep the remembered origin.
|
||||
if let placement = self.preferredPlacement {
|
||||
if let x = placement.x { frame.origin.x = x }
|
||||
if let y = placement.y { frame.origin.y = y }
|
||||
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
|
||||
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
|
||||
}
|
||||
|
||||
self.setPanelFrame(frame, on: screen)
|
||||
}
|
||||
|
||||
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
|
||||
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
|
||||
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
|
||||
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
|
||||
let x = visible.maxX - w - CanvasLayout.defaultPadding
|
||||
let y = visible.maxY - h - CanvasLayout.defaultPadding
|
||||
return NSRect(x: x, y: y, width: w, height: h)
|
||||
}
|
||||
|
||||
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
|
||||
guard let panel = self.window else { return }
|
||||
let s = screen ?? panel.screen ?? NSScreen.main
|
||||
let constrained: NSRect
|
||||
if let s {
|
||||
constrained = panel.constrainFrameRect(frame, to: s)
|
||||
} else {
|
||||
constrained = frame
|
||||
}
|
||||
panel.setFrame(constrained, display: false)
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
@MainActor
|
||||
func webView(
|
||||
_: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
|
||||
{
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased()
|
||||
|
||||
// Keep web content inside the panel when reasonable.
|
||||
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
|
||||
if scheme == CanvasScheme.scheme
|
||||
|| scheme == "https"
|
||||
|| scheme == "http"
|
||||
|| scheme == "about"
|
||||
|| scheme == "blob"
|
||||
|| scheme == "data"
|
||||
|| scheme == "javascript"
|
||||
{
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
|
||||
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
|
||||
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
|
||||
NSWorkspace.shared.open(
|
||||
[url],
|
||||
withApplicationAt: appURL,
|
||||
configuration: NSWorkspace.OpenConfiguration(),
|
||||
completionHandler: nil)
|
||||
} else {
|
||||
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
|
||||
}
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_: Notification) {
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func windowDidMove(_: Notification) {
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
func windowDidEndLiveResize(_: Notification) {
|
||||
self.persistFrameIfPanel()
|
||||
}
|
||||
|
||||
private func persistFrameIfPanel() {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func sanitizeSessionKey(_ key: String) -> String {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "main" }
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
|
||||
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
|
||||
"clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))"
|
||||
}
|
||||
|
||||
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
|
||||
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
|
||||
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
|
||||
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
|
||||
return rect
|
||||
}
|
||||
|
||||
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hover chrome container
|
||||
|
||||
private final class HoverChromeContainerView: NSView {
|
||||
private let content: NSView
|
||||
private let chrome: CanvasChromeOverlayView
|
||||
private var tracking: NSTrackingArea?
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
init(containing content: NSView) {
|
||||
self.content = content
|
||||
self.chrome = CanvasChromeOverlayView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
|
||||
self.content.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.addSubview(self.content)
|
||||
|
||||
self.chrome.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.chrome.alphaValue = 0
|
||||
self.chrome.onClose = { [weak self] in self?.onClose?() }
|
||||
self.addSubview(self.chrome)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.content.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
|
||||
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let area = NSTrackingArea(
|
||||
rect: self.bounds,
|
||||
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
|
||||
owner: self,
|
||||
userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
private final class CanvasDragHandleView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
private final class CanvasResizeHandleView: NSView {
|
||||
private var startPoint: NSPoint = .zero
|
||||
private var startFrame: NSRect = .zero
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window else { return }
|
||||
_ = window.makeFirstResponder(self)
|
||||
self.startPoint = NSEvent.mouseLocation
|
||||
self.startFrame = window.frame
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
|
||||
override func mouseDragged(with _: NSEvent) {
|
||||
guard let window else { return }
|
||||
let current = NSEvent.mouseLocation
|
||||
let dx = current.x - self.startPoint.x
|
||||
let dy = current.y - self.startPoint.y
|
||||
|
||||
var frame = self.startFrame
|
||||
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
|
||||
frame.origin.y += dy
|
||||
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
|
||||
|
||||
if let screen = window.screen {
|
||||
frame = window.constrainFrameRect(frame, to: screen)
|
||||
}
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CanvasChromeOverlayView: NSView {
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||
private let closeButton: NSButton = {
|
||||
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||
let btn = NSButton(image: img, target: nil, action: nil)
|
||||
btn.isBordered = false
|
||||
btn.bezelStyle = .regularSquare
|
||||
btn.imageScaling = .scaleProportionallyDown
|
||||
btn.contentTintColor = NSColor.secondaryLabelColor
|
||||
btn.toolTip = "Close"
|
||||
return btn
|
||||
}()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.borderWidth = 1
|
||||
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
||||
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
|
||||
|
||||
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.dragHandle.wantsLayer = true
|
||||
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.dragHandle)
|
||||
|
||||
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.resizeHandle.wantsLayer = true
|
||||
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
self.addSubview(self.resizeHandle)
|
||||
|
||||
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.closeButton.target = self
|
||||
self.closeButton.action = #selector(self.handleClose)
|
||||
self.addSubview(self.closeButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
|
||||
|
||||
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
|
||||
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
|
||||
|
||||
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
|
||||
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||
guard self.alphaValue > 0.02 else { return nil }
|
||||
|
||||
if self.closeButton.frame.contains(point) { return self.closeButton }
|
||||
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
||||
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc private func handleClose() {
|
||||
self.onClose?()
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseEntered(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.12
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseExited(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.16
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
@State private var configModel: String = ""
|
||||
@State private var customModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelError: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@State private var allowAutosave = false
|
||||
@State private var heartbeatMinutes: Int?
|
||||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||
@AppStorage(webChatEnabledKey) private var webChatEnabled: Bool = true
|
||||
@AppStorage(webChatPortKey) private var webChatPort: Int = 18788
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.reply.agent/session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LabeledContent("Model") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text(
|
||||
"\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 360)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 320)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Heartbeat") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
|
||||
Text("Heartbeats keep Pi sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
LabeledContent("Web chat") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Enable embedded web chat (loopback only)", isOn: self.$webChatEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.frame(width: 320, alignment: .leading)
|
||||
HStack(spacing: 8) {
|
||||
Text("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
self.loadConfig()
|
||||
await self.loadModels()
|
||||
self.allowAutosave = true
|
||||
}
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
}
|
||||
|
||||
private func loadConfig() {
|
||||
let parsed = self.loadConfigDict()
|
||||
let inbound = parsed["inbound"] as? [String: Any]
|
||||
let reply = inbound?["reply"] as? [String: Any]
|
||||
let agent = reply?["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = reply?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = reply?["heartbeatBody"] as? String
|
||||
|
||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
self.customModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
self.customModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
private func saveConfig() async {
|
||||
guard !self.configSaving else { return }
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
var root = self.loadConfigDict()
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||||
var agent = reply["agent"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
|
||||
reply["agent"] = agent
|
||||
|
||||
if let heartbeatMinutes {
|
||||
reply["heartbeatMinutes"] = heartbeatMinutes
|
||||
}
|
||||
|
||||
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
reply["heartbeatBody"] = trimmedBody
|
||||
}
|
||||
|
||||
inbound["reply"] = reply
|
||||
root["inbound"] = inbound
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.configURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func loadConfigDict() -> [String: Any] {
|
||||
let url = self.configURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return [:] }
|
||||
return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
|
||||
}
|
||||
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelError = nil
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) {
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
}
|
||||
self.modelsLoading = false
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard
|
||||
!chosenId.isEmpty,
|
||||
let choice = self.models.first(where: { $0.id == chosenId }),
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConfigSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "connection")
|
||||
|
||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||
/// managing the control-channel SSH tunnel, and cleaning up WebChat tunnels.
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
switch mode {
|
||||
case .local:
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .local)
|
||||
} catch {
|
||||
// Control channel will mark itself degraded; nothing else to do here.
|
||||
self.logger.error(
|
||||
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
if paused {
|
||||
GatewayProcessManager.shared.stop()
|
||||
} else {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
||||
|
||||
case .remote:
|
||||
// Never run a local gateway in remote mode.
|
||||
GatewayProcessManager.shared.stop()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
} catch {
|
||||
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
let launchdLabel = "com.steipete.clawdis"
|
||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||
let currentOnboardingVersion = 3
|
||||
let pauseDefaultsKey = "clawdis.pauseEnabled"
|
||||
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
|
||||
let swabbleEnabledKey = "clawdis.swabbleEnabled"
|
||||
let swabbleTriggersKey = "clawdis.swabbleTriggers"
|
||||
let voiceWakeTriggerChimeKey = "clawdis.voiceWakeTriggerChime"
|
||||
let voiceWakeSendChimeKey = "clawdis.voiceWakeSendChime"
|
||||
let showDockIconKey = "clawdis.showDockIcon"
|
||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||
let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
||||
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
||||
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
||||
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
||||
let iconOverrideKey = "clawdis.iconOverride"
|
||||
let connectionModeKey = "clawdis.connectionMode"
|
||||
let remoteTargetKey = "clawdis.remoteTarget"
|
||||
let remoteIdentityKey = "clawdis.remoteIdentity"
|
||||
let remoteProjectRootKey = "clawdis.remoteProjectRoot"
|
||||
let webChatEnabledKey = "clawdis.webChatEnabled"
|
||||
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
||||
let webChatPortKey = "clawdis.webChatPort"
|
||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
||||
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||
let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Context usage card shown at the top of the menubar menu.
|
||||
struct ContextMenuCardView: View {
|
||||
private let width: CGFloat
|
||||
private let padding: CGFloat = 10
|
||||
private let barHeight: CGFloat = 4
|
||||
|
||||
@State private var rows: [SessionRow] = []
|
||||
@State private var activeCount: Int = 0
|
||||
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
init(width: CGFloat) {
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.rows.isEmpty {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(self.padding)
|
||||
.frame(width: self.width, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.04))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task { await self.reload() }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let count = self.activeCount
|
||||
if count == 1 { return "1 session · 24h" }
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
private var contentWidth: CGFloat {
|
||||
max(1, self.width - (self.padding * 2))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
let width = self.contentWidth
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(row.tokens.contextSummaryShort)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.frame(width: width)
|
||||
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
width: width,
|
||||
height: self.barHeight)
|
||||
}
|
||||
.frame(width: width)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reload() async {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
|
||||
guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
|
||||
self.rows = []
|
||||
self.activeCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let active = loaded.filter { row in
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}
|
||||
|
||||
let main = loaded.first(where: { $0.key == "main" })
|
||||
var merged = active
|
||||
if let main, !merged.contains(where: { $0.key == "main" }) {
|
||||
merged.insert(main, at: 0)
|
||||
}
|
||||
|
||||
merged.sort { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
self.rows = merged
|
||||
self.activeCount = active.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContextUsageBar: View {
|
||||
let usedTokens: Int
|
||||
let contextTokens: Int
|
||||
var width: CGFloat?
|
||||
var height: CGFloat = 6
|
||||
|
||||
private var clampedFractionUsed: Double {
|
||||
guard self.contextTokens > 0 else { return 0 }
|
||||
return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens)))
|
||||
}
|
||||
|
||||
private var percentUsed: Int? {
|
||||
guard self.contextTokens > 0, self.usedTokens > 0 else { return nil }
|
||||
return min(100, Int(round(self.clampedFractionUsed * 100)))
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
guard let pct = self.percentUsed else { return .secondary }
|
||||
if pct >= 95 { return Color(nsColor: .systemRed) }
|
||||
if pct >= 80 { return Color(nsColor: .systemOrange) }
|
||||
if pct >= 60 { return Color(nsColor: .systemYellow) }
|
||||
return Color(nsColor: .systemGreen)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let fraction = self.clampedFractionUsed
|
||||
Group {
|
||||
if let width = self.width, width > 0 {
|
||||
self.barBody(width: width, fraction: fraction)
|
||||
.frame(width: width, height: self.height)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
self.barBody(width: proxy.size.width, fraction: fraction)
|
||||
.frame(width: proxy.size.width, height: self.height)
|
||||
}
|
||||
.frame(height: self.height)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Context usage")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if self.contextTokens <= 0 { return "Unknown context window" }
|
||||
let pct = Int(round(self.clampedFractionUsed * 100))
|
||||
return "\(pct) percent used"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
||||
let radius = self.height / 2
|
||||
let trackFill = Color.white.opacity(0.12)
|
||||
let trackStroke = Color.white.opacity(0.18)
|
||||
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(trackFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.strokeBorder(trackStroke, lineWidth: 0.75)
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(self.tint)
|
||||
.frame(width: fillWidth)
|
||||
.mask {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct ControlHeartbeatEvent: Codable {
|
||||
let ts: Double
|
||||
let status: String
|
||||
let to: String?
|
||||
let preview: String?
|
||||
let durationMs: Double?
|
||||
let hasMedia: Bool?
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||
var id: String { "\(self.runId)-\(self.seq)" }
|
||||
let runId: String
|
||||
let seq: Int
|
||||
let stream: String
|
||||
let ts: Double
|
||||
let data: [String: AnyCodable]
|
||||
let summary: String?
|
||||
}
|
||||
|
||||
enum ControlChannelError: Error, LocalizedError {
|
||||
case disconnected
|
||||
case badResponse(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .disconnected: "Control channel disconnected"
|
||||
case let .badResponse(msg): msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ControlChannel: ObservableObject {
|
||||
static let shared = ControlChannel()
|
||||
|
||||
enum Mode {
|
||||
case local
|
||||
case remote(target: String, identity: String)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case degraded(String)
|
||||
}
|
||||
|
||||
@Published private(set) var state: ConnectionState = .disconnected
|
||||
@Published private(set) var lastPingMs: Double?
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
|
||||
private var eventTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
}
|
||||
|
||||
func configure() async {
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
switch mode {
|
||||
case .local:
|
||||
await self.configure()
|
||||
case let .remote(target, identity):
|
||||
do {
|
||||
_ = (target, identity)
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
do {
|
||||
let start = Date()
|
||||
var params: [String: AnyHashable]?
|
||||
if let timeout {
|
||||
params = ["timeout": AnyHashable(Int(timeout * 1000))]
|
||||
}
|
||||
let timeoutMs = (timeout ?? 15) * 1000
|
||||
let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
|
||||
let ms = Date().timeIntervalSince(start) * 1000
|
||||
self.lastPingMs = ms
|
||||
self.state = .connected
|
||||
return payload
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
throw ControlChannelError.badResponse(message)
|
||||
}
|
||||
}
|
||||
|
||||
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
|
||||
let data = try await self.request(method: "last-heartbeat")
|
||||
return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data)
|
||||
}
|
||||
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: AnyHashable]? = nil,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
let data = try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
|
||||
self.state = .connected
|
||||
return data
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
throw ControlChannelError.badResponse(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func friendlyGatewayMessage(_ error: Error) -> String {
|
||||
// Map URLSession/WS errors into user-facing, actionable text.
|
||||
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
|
||||
return desc
|
||||
}
|
||||
|
||||
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
||||
if let urlErr = error as? URLError,
|
||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
return "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment or clear it on the gateway. Reason: \(reason)"
|
||||
}
|
||||
|
||||
// Common misfire: we connected to localhost:18789 but the port is occupied
|
||||
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
|
||||
// The gateway handshake returns something we can't parse, which currently
|
||||
// surfaces as "hello failed (unexpected response)". Give the user a pointer
|
||||
// to free the port instead of a vague message.
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == "Gateway",
|
||||
nsError.localizedDescription.contains("hello failed (unexpected response)")
|
||||
{
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return """
|
||||
Gateway handshake got non-gateway data on localhost:\(port).
|
||||
Another process is using that port or the SSH forward failed.
|
||||
Stop the local gateway/port-forward on \(port) and retry Remote mode.
|
||||
"""
|
||||
}
|
||||
|
||||
if let urlError = error as? URLError {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
switch urlError.code {
|
||||
case .cancelled:
|
||||
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
|
||||
case .cannotFindHost, .cannotConnectToHost:
|
||||
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
|
||||
case .networkConnectionLost:
|
||||
return "Gateway connection dropped; gateway likely restarted—retry."
|
||||
case .timedOut:
|
||||
return "Gateway request timed out; check gateway on localhost:\(port)."
|
||||
case .notConnectedToInternet:
|
||||
return "No network connectivity; cannot reach gateway."
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nsError.domain == "Gateway", nsError.code == 5 {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return "Gateway request timed out; check the gateway process on localhost:\(port)."
|
||||
}
|
||||
|
||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||
return "Gateway error: \(detail)"
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
_ = try await self.request(method: "system-event", params: merged)
|
||||
}
|
||||
|
||||
private func startEventStream() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "agent":
|
||||
if let payload = evt.payload,
|
||||
let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self)
|
||||
{
|
||||
AgentEventStore.shared.append(agent)
|
||||
self.routeWorkActivity(from: agent)
|
||||
}
|
||||
case let .event(evt) where evt.event == "heartbeat":
|
||||
if let payload = evt.payload,
|
||||
let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self),
|
||||
let data = try? JSONEncoder().encode(heartbeat)
|
||||
{
|
||||
NotificationCenter.default.post(name: .controlHeartbeat, object: data)
|
||||
}
|
||||
case let .event(evt) where evt.event == "shutdown":
|
||||
self.state = .degraded("gateway shutdown")
|
||||
case .snapshot:
|
||||
self.state = .connected
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func routeWorkActivity(from event: ControlAgentEvent) {
|
||||
// We currently treat VoiceWake as the "main" session for UI purposes.
|
||||
// In the future, the gateway can include a sessionKey to distinguish runs.
|
||||
let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main"
|
||||
|
||||
switch event.stream.lowercased() {
|
||||
case "job":
|
||||
if let state = event.data["state"]?.value as? String {
|
||||
WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state)
|
||||
}
|
||||
case "tool":
|
||||
let phase = event.data["phase"]?.value as? String ?? ""
|
||||
let name = event.data["name"]?.value as? String
|
||||
let meta = event.data["meta"]?.value as? String
|
||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
||||
WorkActivityStore.shared.handleTool(
|
||||
sessionKey: sessionKey,
|
||||
phase: phase,
|
||||
name: name,
|
||||
meta: meta,
|
||||
args: args)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let controlHeartbeat = Notification.Name("clawdis.control.heartbeat")
|
||||
static let controlAgentEvent = Notification.Name("clawdis.control.agent")
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum ControlRequestHandler {
|
||||
static func process(
|
||||
request: Request,
|
||||
notifier: NotificationManager = NotificationManager(),
|
||||
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
|
||||
{
|
||||
// Keep `status` responsive even if the main actor is busy.
|
||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
if paused {
|
||||
switch request {
|
||||
case .status:
|
||||
break
|
||||
default:
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
}
|
||||
|
||||
switch request {
|
||||
case let .notify(title, body, sound, priority, delivery):
|
||||
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenDelivery = delivery ?? .system
|
||||
|
||||
switch chosenDelivery {
|
||||
case .system:
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
|
||||
case .overlay:
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true)
|
||||
|
||||
case .auto:
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
}
|
||||
|
||||
case let .ensurePermissions(caps, interactive):
|
||||
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
|
||||
let ok = missing.isEmpty
|
||||
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||
return Response(ok: ok, message: msg)
|
||||
|
||||
case .status:
|
||||
return paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
|
||||
|
||||
case .rpcStatus:
|
||||
let result = await AgentRPC.shared.status()
|
||||
return Response(ok: result.ok, message: result.error)
|
||||
|
||||
case let .screenshot(displayID, windowID, _):
|
||||
let authorized = await PermissionManager
|
||||
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) {
|
||||
return Response(ok: true, payload: data)
|
||||
}
|
||||
return Response(ok: false, message: "screenshot failed")
|
||||
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
if needsSR {
|
||||
let authorized = await PermissionManager
|
||||
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
}
|
||||
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
|
||||
|
||||
case let .agent(message, thinking, session, deliver, to):
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let rpcResult = await AgentRPC.shared.send(
|
||||
text: trimmed,
|
||||
thinking: thinking,
|
||||
sessionKey: sessionKey,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: nil)
|
||||
return rpcResult.ok
|
||||
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||
|
||||
case let .canvasShow(session, path, placement):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let dir = try await MainActor.run { try CanvasManager.shared.show(
|
||||
sessionKey: session,
|
||||
path: path,
|
||||
placement: placement) }
|
||||
return Response(ok: true, message: dir)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
case let .canvasHide(session):
|
||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||
return Response(ok: true)
|
||||
|
||||
case let .canvasGoto(session, path, placement):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
try await MainActor.run { try CanvasManager.shared.goto(
|
||||
sessionKey: session,
|
||||
path: path,
|
||||
placement: placement) }
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard canvasEnabled else {
|
||||
return Response(ok: false, message: "Canvas disabled by user")
|
||||
}
|
||||
do {
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
|
||||
return Response(ok: true, message: path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
|
||||
case .nodeList:
|
||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||
let payload = (try? JSONSerialization.data(
|
||||
withJSONObject: ["connectedNodeIds": ids],
|
||||
options: [.prettyPrinted]))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
?? "{}"
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
|
||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||
do {
|
||||
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
||||
if res.ok {
|
||||
let payload = res.payloadJSON ?? ""
|
||||
return Response(ok: true, payload: Data(payload.utf8))
|
||||
}
|
||||
let errText = res.error?.message ?? "node invoke failed"
|
||||
return Response(ok: false, message: errText)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
import Darwin
|
||||
import OSLog
|
||||
|
||||
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
|
||||
/// without a launchd MachService. Listens on `controlSocketPath`.
|
||||
final actor ControlSocketServer {
|
||||
nonisolated private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
|
||||
|
||||
private var listenFD: Int32 = -1
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
|
||||
private let socketPath: String
|
||||
private let maxRequestBytes: Int
|
||||
private let allowedTeamIDs: Set<String>
|
||||
private let requestTimeoutSec: TimeInterval
|
||||
|
||||
init(
|
||||
socketPath: String = controlSocketPath,
|
||||
maxRequestBytes: Int = 512 * 1024,
|
||||
allowedTeamIDs: Set<String> = ["Y5PE65HELJ"],
|
||||
requestTimeoutSec: TimeInterval = 5)
|
||||
{
|
||||
self.socketPath = socketPath
|
||||
self.maxRequestBytes = maxRequestBytes
|
||||
self.allowedTeamIDs = allowedTeamIDs
|
||||
self.requestTimeoutSec = requestTimeoutSec
|
||||
}
|
||||
|
||||
private static func disableSigPipe(fd: Int32) {
|
||||
var one: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||
}
|
||||
|
||||
func start() {
|
||||
// Already running
|
||||
guard self.listenFD == -1 else { return }
|
||||
|
||||
let path = self.socketPath
|
||||
let fm = FileManager.default
|
||||
// Ensure directory exists
|
||||
let dir = (path as NSString).deletingLastPathComponent
|
||||
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
// Remove stale socket
|
||||
unlink(path)
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return }
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let copied = path.withCString { cstr -> Int in
|
||||
strlcpy(&addr.sun_path.0, cstr, capacity)
|
||||
}
|
||||
if copied >= capacity {
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
|
||||
let len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer<sockaddr>(OpaquePointer($0)) }), len) != 0 {
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
// Restrict permissions: owner rw
|
||||
chmod(path, S_IRUSR | S_IWUSR)
|
||||
if listen(fd, SOMAXCONN) != 0 {
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
|
||||
self.listenFD = fd
|
||||
|
||||
let allowedTeamIDs = self.allowedTeamIDs
|
||||
let maxRequestBytes = self.maxRequestBytes
|
||||
let requestTimeoutSec = self.requestTimeoutSec
|
||||
self.acceptTask = Task.detached(priority: .utility) {
|
||||
await Self.acceptLoop(
|
||||
listenFD: fd,
|
||||
allowedTeamIDs: allowedTeamIDs,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
requestTimeoutSec: requestTimeoutSec)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.acceptTask?.cancel()
|
||||
self.acceptTask = nil
|
||||
if self.listenFD != -1 {
|
||||
close(self.listenFD)
|
||||
self.listenFD = -1
|
||||
}
|
||||
unlink(self.socketPath)
|
||||
}
|
||||
|
||||
private nonisolated static func acceptLoop(
|
||||
listenFD: Int32,
|
||||
allowedTeamIDs: Set<String>,
|
||||
maxRequestBytes: Int,
|
||||
requestTimeoutSec: TimeInterval) async
|
||||
{
|
||||
while !Task.isCancelled {
|
||||
var addr = sockaddr()
|
||||
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
let client = accept(listenFD, &addr, &len)
|
||||
if client < 0 {
|
||||
if errno == EINTR { continue }
|
||||
// Socket was likely closed as part of stop().
|
||||
if errno == EBADF || errno == EINVAL { return }
|
||||
self.logger.error("accept failed: \(errno, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
Self.disableSigPipe(fd: client)
|
||||
Task.detached(priority: .utility) {
|
||||
defer { close(client) }
|
||||
await Self.handleClient(
|
||||
fd: client,
|
||||
allowedTeamIDs: allowedTeamIDs,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
requestTimeoutSec: requestTimeoutSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func handleClient(
|
||||
fd: Int32,
|
||||
allowedTeamIDs: Set<String>,
|
||||
maxRequestBytes: Int,
|
||||
requestTimeoutSec: TimeInterval) async
|
||||
{
|
||||
guard self.isAllowed(fd: fd, allowedTeamIDs: allowedTeamIDs) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let request = try self.readRequest(
|
||||
fd: fd,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
timeoutSec: requestTimeoutSec)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await ControlRequestHandler.process(request: request)
|
||||
try self.writeResponse(fd: fd, response: response)
|
||||
} catch {
|
||||
self.logger.error("socket request failed: \(error.localizedDescription, privacy: .public)")
|
||||
let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)")
|
||||
try? self.writeResponse(fd: fd, response: resp)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func readRequest(
|
||||
fd: Int32,
|
||||
maxRequestBytes: Int,
|
||||
timeoutSec: TimeInterval) throws -> Request?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(timeoutSec)
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 16 * 1024)
|
||||
let bufferSize = buffer.count
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
while true {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 {
|
||||
throw POSIXError(.ETIMEDOUT)
|
||||
}
|
||||
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let sliceMs = max(1.0, min(remaining, 0.25) * 1000.0)
|
||||
let polled = poll(&pfd, 1, Int32(sliceMs))
|
||||
if polled == 0 { continue }
|
||||
if polled < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufferSize) }
|
||||
if n > 0 {
|
||||
data.append(buffer, count: n)
|
||||
if data.count > maxRequestBytes {
|
||||
throw POSIXError(.EMSGSIZE)
|
||||
}
|
||||
if let req = try? decoder.decode(Request.self, from: data) {
|
||||
return req
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return data.isEmpty ? nil : try decoder.decode(Request.self, from: data)
|
||||
}
|
||||
|
||||
if errno == EINTR { continue }
|
||||
if errno == EAGAIN { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func writeResponse(fd: Int32, response: Response) throws {
|
||||
let encoded = try JSONEncoder().encode(response)
|
||||
try encoded.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
var written = 0
|
||||
while written < encoded.count {
|
||||
let n = write(fd, base.advanced(by: written), encoded.count - written)
|
||||
if n > 0 {
|
||||
written += n
|
||||
continue
|
||||
}
|
||||
if n == -1, errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isAllowed(fd: Int32, allowedTeamIDs: Set<String>) -> Bool {
|
||||
var pid: pid_t = 0
|
||||
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
|
||||
let r = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
|
||||
guard r == 0, pid > 0 else { return false }
|
||||
|
||||
// Always require a valid code signature match (TeamID).
|
||||
// This prevents any same-UID process from driving the app's privileged surface.
|
||||
if self.teamIDMatches(pid: pid, allowedTeamIDs: allowedTeamIDs) {
|
||||
return true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
|
||||
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
|
||||
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
|
||||
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
|
||||
self.logger.warning(
|
||||
"allowing unsigned same-UID socket client pid=\(pid, privacy: .public) due to CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1")
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
if let callerUID = self.uid(for: pid) {
|
||||
self.logger.error(
|
||||
"socket client rejected pid=\(pid, privacy: .public) uid=\(callerUID, privacy: .public)")
|
||||
} else {
|
||||
self.logger.error("socket client rejected pid=\(pid, privacy: .public) (uid unknown)")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private nonisolated static func uid(for pid: pid_t) -> uid_t? {
|
||||
var info = kinfo_proc()
|
||||
var size = MemoryLayout.size(ofValue: info)
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
||||
let ok = mib.withUnsafeMutableBufferPointer { mibPtr -> Bool in
|
||||
return sysctl(mibPtr.baseAddress, u_int(mibPtr.count), &info, &size, nil, 0) == 0
|
||||
}
|
||||
return ok ? info.kp_eproc.e_ucred.cr_uid : nil
|
||||
}
|
||||
|
||||
private nonisolated static func teamIDMatches(pid: pid_t, allowedTeamIDs: Set<String>) -> Bool {
|
||||
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
|
||||
var secCode: SecCode?
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
|
||||
let code = secCode else { return false }
|
||||
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let sCode = staticCode else { return false }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any],
|
||||
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return allowedTeamIDs.contains(teamID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CritterStatusLabel: View {
|
||||
var isPaused: Bool
|
||||
var isWorking: Bool
|
||||
var earBoostActive: Bool
|
||||
var blinkTick: Int
|
||||
var sendCelebrationTick: Int
|
||||
var gatewayStatus: GatewayProcessManager.Status
|
||||
var animationsEnabled: Bool
|
||||
var iconState: IconState
|
||||
|
||||
@State private var blinkAmount: CGFloat = 0
|
||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State private var wiggleAngle: Double = 0
|
||||
@State private var wiggleOffset: CGFloat = 0
|
||||
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
|
||||
@State private var legWiggle: CGFloat = 0
|
||||
@State private var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State private var earWiggle: CGFloat = 0
|
||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||
|
||||
private var isWorkingNow: Bool {
|
||||
self.iconState.isWorking || self.isWorking
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
self.iconImage
|
||||
.frame(width: 18, height: 18)
|
||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||
.offset(x: self.wiggleOffset)
|
||||
.onReceive(self.ticker) { now in
|
||||
guard self.animationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
return
|
||||
}
|
||||
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
if self.gatewayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.gatewayBadgeColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
|
||||
private var iconImage: Image {
|
||||
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||
CritterIconRenderer.Badge(
|
||||
symbolName: self.iconState.badgeSymbolName,
|
||||
prominence: prominence)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
if self.isPaused {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
badge: badge))
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggle() {
|
||||
let targetAngle = Double.random(in: -4.5...4.5)
|
||||
let targetOffset = CGFloat.random(in: -0.5...0.5)
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = targetAngle
|
||||
self.wiggleOffset = targetOffset
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleLegs() {
|
||||
let target = CGFloat.random(in: 0.35...0.9)
|
||||
withAnimation(.easeInOut(duration: 0.14)) {
|
||||
self.legWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scurry() {
|
||||
let target = CGFloat.random(in: 0.7...1.0)
|
||||
withAnimation(.easeInOut(duration: 0.12)) {
|
||||
self.legWiggle = target
|
||||
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||
withAnimation(.easeOut(duration: 0.16)) {
|
||||
self.legWiggle = 0.25
|
||||
self.wiggleOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wiggleEars() {
|
||||
let target = CGFloat.random(in: -1.2...1.2)
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
|
||||
self.earWiggle = target
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
|
||||
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { self.earWiggle = 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRandomTimers(from date: Date) {
|
||||
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
private var gatewayNeedsAttention: Bool {
|
||||
switch self.gatewayStatus {
|
||||
case .failed, .stopped:
|
||||
!self.isPaused
|
||||
case .starting, .restarting, .running, .attachedExisting:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayBadgeColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .failed: .red
|
||||
case .stopped: .orange
|
||||
default: .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CritterIconRenderer {
|
||||
private static let size = NSSize(width: 18, height: 18)
|
||||
|
||||
struct Badge {
|
||||
let symbolName: String
|
||||
let prominence: IconState.BadgeProminence
|
||||
}
|
||||
|
||||
private struct Canvas {
|
||||
let w: CGFloat
|
||||
let h: CGFloat
|
||||
let snapX: (CGFloat) -> CGFloat
|
||||
let snapY: (CGFloat) -> CGFloat
|
||||
let context: CGContext
|
||||
}
|
||||
|
||||
static func makeIcon(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false,
|
||||
badge: Badge? = nil) -> NSImage
|
||||
{
|
||||
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
|
||||
let pixelsWide = 36
|
||||
let pixelsHigh = 36
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: pixelsWide,
|
||||
pixelsHigh: pixelsHigh,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bitmapFormat: [],
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0)
|
||||
else {
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
rep.size = self.size
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
if let context = NSGraphicsContext(bitmapImageRep: rep) {
|
||||
NSGraphicsContext.current = context
|
||||
context.imageInterpolation = .none
|
||||
context.cgContext.setShouldAntialias(false)
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
|
||||
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
||||
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
||||
|
||||
let w = snapX(size.width)
|
||||
let h = snapY(size.height)
|
||||
|
||||
let bodyW = snapX(w * 0.78)
|
||||
let bodyH = snapY(h * 0.58)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.36)
|
||||
let bodyCorner = snapX(w * 0.09)
|
||||
|
||||
let earW = snapX(w * 0.22)
|
||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||
let earCorner = snapX(earW * 0.24)
|
||||
let leftEarRect = CGRect(
|
||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = snapX(w * 0.11)
|
||||
let legH = snapY(h * 0.26)
|
||||
let legSpacing = snapX(w * 0.085)
|
||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||
let legStartX = snapX((w - legsWidth) / 2)
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
|
||||
let eyeOpen = max(0.05, 1 - blink)
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeH = snapY(bodyH * 0.26 * eyeOpen)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
|
||||
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
|
||||
cornerWidth: bodyCorner,
|
||||
cornerHeight: bodyCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightEarRect,
|
||||
cornerWidth: earCorner,
|
||||
cornerHeight: earCorner,
|
||||
transform: nil))
|
||||
for i in 0..<4 {
|
||||
let x = legStartX + CGFloat(i) * (legW + legSpacing)
|
||||
let lift = (i % 2 == 0 ? legLift : -legLift)
|
||||
let rect = CGRect(
|
||||
x: x,
|
||||
y: legYBase + lift,
|
||||
width: legW,
|
||||
height: legH * (1 - 0.12 * legWiggle))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rect,
|
||||
cornerWidth: legW * 0.34,
|
||||
cornerHeight: legW * 0.34,
|
||||
transform: nil))
|
||||
}
|
||||
context.cgContext.fillPath()
|
||||
|
||||
context.cgContext.saveGState()
|
||||
context.cgContext.setBlendMode(CGBlendMode.clear)
|
||||
|
||||
let leftCenter = CGPoint(x: snapX(w / 2 - eyeOffset), y: snapY(eyeY))
|
||||
let rightCenter = CGPoint(x: snapX(w / 2 + eyeOffset), y: snapY(eyeY))
|
||||
|
||||
if earHoles || earScale > 1.05 {
|
||||
let holeW = snapX(earW * 0.6)
|
||||
let holeH = snapY(earH * 0.46)
|
||||
let holeCorner = snapX(holeW * 0.34)
|
||||
let leftHoleRect = CGRect(
|
||||
x: snapX(leftEarRect.midX - holeW / 2),
|
||||
y: snapY(leftEarRect.midY - holeH / 2 + earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: snapX(rightEarRect.midX - holeW / 2),
|
||||
y: snapY(rightEarRect.midY - holeH / 2 + earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
context.cgContext.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x + eyeW / 2), y: snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(x: snapX(leftCenter.x - eyeW / 2), y: snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x - eyeW / 2), y: snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(x: snapX(rightCenter.x + eyeW / 2), y: snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
context.cgContext.addPath(left)
|
||||
context.cgContext.addPath(right)
|
||||
context.cgContext.fillPath()
|
||||
context.cgContext.restoreGState()
|
||||
|
||||
if let badge {
|
||||
self.drawBadge(
|
||||
badge,
|
||||
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
|
||||
}
|
||||
} else {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
|
||||
let image = NSImage(size: size)
|
||||
image.addRepresentation(rep)
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
|
||||
let strength: CGFloat = switch badge.prominence {
|
||||
case .primary: 1.0
|
||||
case .secondary: 0.58
|
||||
case .overridden: 0.85
|
||||
}
|
||||
|
||||
// Bigger, higher-contrast badge:
|
||||
// - Increase diameter so tool activity is noticeable.
|
||||
// - Use a filled "puck" background with a fully-opaque SF Symbol on top.
|
||||
// (The menu bar image is rendered as a template, so "knocking out" the symbol makes it invisible.)
|
||||
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
||||
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
||||
let rect = CGRect(
|
||||
x: canvas.snapX(canvas.w - diameter - margin),
|
||||
y: canvas.snapY(margin),
|
||||
width: diameter,
|
||||
height: diameter)
|
||||
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setShouldAntialias(true)
|
||||
|
||||
// Clear the underlying pixels so the badge stays readable over the critter.
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
|
||||
canvas.context.fillPath()
|
||||
canvas.context.restoreGState()
|
||||
|
||||
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
|
||||
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
|
||||
|
||||
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
|
||||
canvas.context.addEllipse(in: rect)
|
||||
canvas.context.fillPath()
|
||||
|
||||
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
|
||||
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
|
||||
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
||||
|
||||
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
||||
let pointSize = max(6.0, diameter * 0.80)
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold)
|
||||
let symbol = base.withSymbolConfiguration(config) ?? base
|
||||
symbol.isTemplate = true
|
||||
|
||||
let symbolRect = rect.insetBy(dx: diameter * 0.19, dy: diameter * 0.19)
|
||||
symbol.draw(
|
||||
in: symbolRect,
|
||||
from: .zero,
|
||||
operation: .sourceOver,
|
||||
fraction: min(1.0, 0.96 + 0.04 * strength),
|
||||
respectFlipped: true,
|
||||
hints: nil)
|
||||
}
|
||||
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CronJobsStore: ObservableObject {
|
||||
static let shared = CronJobsStore()
|
||||
|
||||
@Published var jobs: [CronJob] = []
|
||||
@Published var selectedJobId: String?
|
||||
@Published var runEntries: [CronRunLogEntry] = []
|
||||
|
||||
@Published var isLoadingJobs = false
|
||||
@Published var isLoadingRuns = false
|
||||
@Published var lastError: String?
|
||||
@Published var statusMessage: String?
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var runsTask: Task<Void, Never>?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
private let interval: TimeInterval = 30
|
||||
private let isPreview: Bool
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.eventTask == nil else { return }
|
||||
self.startGatewaySubscription()
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshJobs()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refreshJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.refreshTask?.cancel()
|
||||
self.refreshTask = nil
|
||||
self.runsTask?.cancel()
|
||||
self.runsTask = nil
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = nil
|
||||
self.pollTask?.cancel()
|
||||
self.pollTask = nil
|
||||
}
|
||||
|
||||
func refreshJobs() async {
|
||||
guard !self.isLoadingJobs else { return }
|
||||
self.isLoadingJobs = true
|
||||
self.lastError = nil
|
||||
self.statusMessage = nil
|
||||
defer { self.isLoadingJobs = false }
|
||||
|
||||
do {
|
||||
let data = try await self.request(
|
||||
method: "cron.list",
|
||||
params: ["includeDisabled": true])
|
||||
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
|
||||
self.jobs = res.jobs
|
||||
if self.jobs.isEmpty {
|
||||
self.statusMessage = "No cron jobs yet."
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func refreshRuns(jobId: String, limit: Int = 200) async {
|
||||
guard !self.isLoadingRuns else { return }
|
||||
self.isLoadingRuns = true
|
||||
defer { self.isLoadingRuns = false }
|
||||
|
||||
do {
|
||||
let data = try await self.request(
|
||||
method: "cron.runs",
|
||||
params: ["id": jobId, "limit": limit])
|
||||
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
|
||||
self.runEntries = res.entries
|
||||
} catch {
|
||||
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func runJob(id: String, force: Bool = true) async {
|
||||
do {
|
||||
_ = try await self.request(
|
||||
method: "cron.run",
|
||||
params: ["id": id, "mode": force ? "force" : "due"],
|
||||
timeoutMs: 20_000)
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func removeJob(id: String) async {
|
||||
do {
|
||||
_ = try await self.request(method: "cron.remove", params: ["id": id])
|
||||
await self.refreshJobs()
|
||||
if self.selectedJobId == id {
|
||||
self.selectedJobId = nil
|
||||
self.runEntries = []
|
||||
}
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func setJobEnabled(id: String, enabled: Bool) async {
|
||||
do {
|
||||
_ = try await self.request(
|
||||
method: "cron.update",
|
||||
params: ["id": id, "patch": ["enabled": enabled]])
|
||||
await self.refreshJobs()
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func upsertJob(
|
||||
id: String?,
|
||||
payload: [String: Any]) async throws
|
||||
{
|
||||
if let id {
|
||||
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
|
||||
} else {
|
||||
_ = try await self.request(method: "cron.add", params: payload)
|
||||
}
|
||||
await self.refreshJobs()
|
||||
}
|
||||
|
||||
// MARK: - Gateway events
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "cron":
|
||||
guard let payload = evt.payload else { return }
|
||||
if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) {
|
||||
self.handle(cronEvent: cronEvt)
|
||||
}
|
||||
case .seqGap:
|
||||
self.scheduleRefresh()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(cronEvent evt: CronEvent) {
|
||||
// Keep UI in sync with the gateway scheduler.
|
||||
self.scheduleRefresh(delayMs: 250)
|
||||
if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId {
|
||||
self.scheduleRunsRefresh(jobId: selected, delayMs: 200)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRefresh(delayMs: Int = 250) {
|
||||
self.refreshTask?.cancel()
|
||||
self.refreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
await self.refreshJobs()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) {
|
||||
self.runsTask?.cancel()
|
||||
self.runsTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
await self.refreshRuns(jobId: jobId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RPC
|
||||
|
||||
private func request(
|
||||
method: String,
|
||||
params: [String: Any]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
|
||||
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
|
||||
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
|
||||
case main
|
||||
case isolated
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
case now
|
||||
case nextHeartbeat = "next-heartbeat"
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronSchedule: Codable, Equatable {
|
||||
case at(atMs: Int)
|
||||
case every(everyMs: Int, anchorMs: Int?)
|
||||
case cron(expr: String, tz: String?)
|
||||
|
||||
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
case .at: "at"
|
||||
case .every: "every"
|
||||
case .cron: "cron"
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "at":
|
||||
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
|
||||
case "every":
|
||||
self = .every(
|
||||
everyMs: try container.decode(Int.self, forKey: .everyMs),
|
||||
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
||||
case "cron":
|
||||
self = .cron(
|
||||
expr: try container.decode(String.self, forKey: .expr),
|
||||
tz: try container.decodeIfPresent(String.self, forKey: .tz))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
in: container,
|
||||
debugDescription: "Unknown schedule kind: \(kind)")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.kind, forKey: .kind)
|
||||
switch self {
|
||||
case let .at(atMs):
|
||||
try container.encode(atMs, forKey: .atMs)
|
||||
case let .every(everyMs, anchorMs):
|
||||
try container.encode(everyMs, forKey: .everyMs)
|
||||
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
|
||||
case let .cron(expr, tz):
|
||||
try container.encode(expr, forKey: .expr)
|
||||
try container.encodeIfPresent(tz, forKey: .tz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CronPayload: Codable, Equatable {
|
||||
case systemEvent(text: String)
|
||||
case agentTurn(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
|
||||
}
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
case .systemEvent: "systemEvent"
|
||||
case .agentTurn: "agentTurn"
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let kind = try container.decode(String.self, forKey: .kind)
|
||||
switch kind {
|
||||
case "systemEvent":
|
||||
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
|
||||
case "agentTurn":
|
||||
self = .agentTurn(
|
||||
message: try container.decode(String.self, forKey: .message),
|
||||
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
|
||||
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
|
||||
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
|
||||
channel: try container.decodeIfPresent(String.self, forKey: .channel),
|
||||
to: try container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
in: container,
|
||||
debugDescription: "Unknown payload kind: \(kind)")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.kind, forKey: .kind)
|
||||
switch self {
|
||||
case let .systemEvent(text):
|
||||
try container.encode(text, forKey: .text)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(thinking, forKey: .thinking)
|
||||
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try container.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try container.encodeIfPresent(channel, forKey: .channel)
|
||||
try container.encodeIfPresent(to, forKey: .to)
|
||||
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CronIsolation: Codable, Equatable {
|
||||
var postToMain: Bool?
|
||||
var postToMainPrefix: String?
|
||||
}
|
||||
|
||||
struct CronJobState: Codable, Equatable {
|
||||
var nextRunAtMs: Int?
|
||||
var runningAtMs: Int?
|
||||
var lastRunAtMs: Int?
|
||||
var lastStatus: String?
|
||||
var lastError: String?
|
||||
var lastDurationMs: Int?
|
||||
}
|
||||
|
||||
struct CronJob: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
var name: String?
|
||||
var enabled: Bool
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
let sessionTarget: CronSessionTarget
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let isolation: CronIsolation?
|
||||
let state: CronJobState
|
||||
|
||||
var displayName: String {
|
||||
let trimmed = (self.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Untitled job" : trimmed
|
||||
}
|
||||
|
||||
var nextRunDate: Date? {
|
||||
guard let ms = self.state.nextRunAtMs else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
}
|
||||
|
||||
var lastRunDate: Date? {
|
||||
guard let ms = self.state.lastRunAtMs else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
struct CronEvent: Codable, Sendable {
|
||||
let jobId: String
|
||||
let action: String
|
||||
let runAtMs: Int?
|
||||
let durationMs: Int?
|
||||
let status: String?
|
||||
let error: String?
|
||||
let nextRunAtMs: Int?
|
||||
}
|
||||
|
||||
struct CronRunLogEntry: Codable, Identifiable, Sendable {
|
||||
var id: String { "\(self.jobId)-\(self.ts)" }
|
||||
|
||||
let ts: Int
|
||||
let jobId: String
|
||||
let action: String
|
||||
let status: String?
|
||||
let error: String?
|
||||
let runAtMs: Int?
|
||||
let durationMs: Int?
|
||||
let nextRunAtMs: Int?
|
||||
|
||||
var date: Date { Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) }
|
||||
var runDate: Date? {
|
||||
guard let runAtMs else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
struct CronListResponse: Codable {
|
||||
let jobs: [CronJob]
|
||||
}
|
||||
|
||||
struct CronRunsResponse: Codable {
|
||||
let entries: [CronRunLogEntry]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CronSettings: View {
|
||||
@ObservedObject var store: CronJobsStore
|
||||
@State private var showEditor = false
|
||||
@State private var editingJob: CronJob?
|
||||
@State private var editorError: String?
|
||||
@State private var isSaving = false
|
||||
@State private var confirmDelete: CronJob?
|
||||
|
||||
init(store: CronJobsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
.sheet(isPresented: self.$showEditor) {
|
||||
CronJobEditor(
|
||||
job: self.editingJob,
|
||||
isSaving: self.$isSaving,
|
||||
error: self.$editorError,
|
||||
onCancel: {
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
},
|
||||
onSave: { payload in
|
||||
Task {
|
||||
await self.save(payload: payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
.alert("Delete cron job?", isPresented: Binding(
|
||||
get: { self.confirmDelete != nil },
|
||||
set: { if !$0 { self.confirmDelete = nil } }))
|
||||
{
|
||||
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = self.confirmDelete {
|
||||
Task { await self.store.removeJob(id: job.id) }
|
||||
}
|
||||
self.confirmDelete = nil
|
||||
}
|
||||
} message: {
|
||||
if let job = self.confirmDelete {
|
||||
Text(job.displayName)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cron")
|
||||
.font(.headline)
|
||||
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.store.refreshJobs() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isLoadingJobs)
|
||||
|
||||
Button {
|
||||
self.editorError = nil
|
||||
self.editingJob = nil
|
||||
self.showEditor = true
|
||||
} label: {
|
||||
Label("New Job", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let err = self.store.lastError {
|
||||
Text("Error: \(err)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
} else if let msg = self.store.statusMessage {
|
||||
Text(msg)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
List(selection: self.$store.selectedJobId) {
|
||||
ForEach(self.store.jobs) { job in
|
||||
self.jobRow(job)
|
||||
.tag(job.id)
|
||||
.contextMenu { self.jobContextMenu(job) }
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 250)
|
||||
|
||||
Divider()
|
||||
|
||||
self.detail
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
if let selected = self.selectedJob {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.detailHeader(selected)
|
||||
self.detailCard(selected)
|
||||
self.runHistoryCard(selected)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Select a job to inspect details and run history.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedJob: CronJob? {
|
||||
guard let id = self.store.selectedJobId else { return nil }
|
||||
return self.store.jobs.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
private func jobRow(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text(job.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
if !job.enabled {
|
||||
StatusPill(text: "disabled", tint: .secondary)
|
||||
} else if let next = job.nextRunDate {
|
||||
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
|
||||
} else {
|
||||
StatusPill(text: "no next run", tint: .secondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func jobContextMenu(_ job: CronJob) -> some View {
|
||||
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
Divider()
|
||||
Button(job.enabled ? "Disable" : "Enable") {
|
||||
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
|
||||
}
|
||||
Button("Edit…") {
|
||||
self.editingJob = job
|
||||
self.editorError = nil
|
||||
self.showEditor = true
|
||||
}
|
||||
Divider()
|
||||
Button("Delete…", role: .destructive) {
|
||||
self.confirmDelete = job
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(_ job: CronJob) -> some View {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(job.displayName)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(job.id)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Enabled", isOn: Binding(
|
||||
get: { job.enabled },
|
||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Edit") {
|
||||
self.editingJob = job
|
||||
self.editorError = nil
|
||||
self.showEditor = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
if let date = job.nextRunDate {
|
||||
Text(date.formatted(date: .abbreviated, time: .standard))
|
||||
} else {
|
||||
Text("—").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Last run") {
|
||||
if let date = job.lastRunDate {
|
||||
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
|
||||
} else {
|
||||
Text("—").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
LabeledContent("Last status") { Text(status) }
|
||||
}
|
||||
if let err = job.state.lastError, !err.isEmpty {
|
||||
Text(err)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
self.payloadSummary(job.payload)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private func runHistoryCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Run history")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await self.store.refreshRuns(jobId: job.id) }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.store.isLoadingRuns)
|
||||
}
|
||||
|
||||
if self.store.isLoadingRuns {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
|
||||
if self.store.runEntries.isEmpty {
|
||||
Text("No run log entries yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.store.runEntries) { entry in
|
||||
self.runRow(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
private func runRow(_ entry: CronRunLogEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
|
||||
Text(entry.date.formatted(date: .abbreviated, time: .standard))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let ms = entry.durationMs {
|
||||
Text("\(ms)ms")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let error = entry.error, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func statusTint(_ status: String?) -> Color {
|
||||
switch (status ?? "").lowercased() {
|
||||
case "ok": .green
|
||||
case "error": .red
|
||||
case "skipped": .orange
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSummary(_ schedule: CronSchedule) -> String {
|
||||
switch schedule {
|
||||
case let .at(atMs):
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
case let .every(everyMs, _):
|
||||
return "every \(self.formatDuration(ms: everyMs))"
|
||||
case let .cron(expr, tz):
|
||||
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
|
||||
return "cron \(expr)"
|
||||
}
|
||||
}
|
||||
|
||||
private func payloadSummary(_ payload: CronPayload) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Payload")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
switch payload {
|
||||
case let .systemEvent(text):
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
HStack(spacing: 8) {
|
||||
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
|
||||
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
|
||||
if (deliver ?? false) {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
|
||||
private func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||
let delta = date.timeIntervalSince(now)
|
||||
if delta <= 0 { return "due" }
|
||||
if delta < 60 { return "in <1m" }
|
||||
let minutes = Int(round(delta / 60))
|
||||
if minutes < 60 { return "in \(minutes)m" }
|
||||
let hours = Int(round(Double(minutes) / 60))
|
||||
if hours < 48 { return "in \(hours)h" }
|
||||
let days = Int(round(Double(hours) / 24))
|
||||
return "in \(days)d"
|
||||
}
|
||||
|
||||
private func save(payload: [String: Any]) async {
|
||||
guard !self.isSaving else { return }
|
||||
self.isSaving = true
|
||||
self.editorError = nil
|
||||
do {
|
||||
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
self.editorError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusPill: View {
|
||||
let text: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
Text(self.text)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.foregroundStyle(self.tint == .secondary ? .secondary : self.tint)
|
||||
.background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private struct CronJobEditor: View {
|
||||
let job: CronJob?
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: Any]) -> Void
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var enabled: Bool = true
|
||||
@State private var sessionTarget: CronSessionTarget = .main
|
||||
@State private var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State private var scheduleKind: ScheduleKind = .every
|
||||
@State private var atDate: Date = Date().addingTimeInterval(60 * 5)
|
||||
@State private var everyText: String = "1h"
|
||||
@State private var cronExpr: String = "0 9 * * 3"
|
||||
@State private var cronTz: String = ""
|
||||
|
||||
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
|
||||
@State private var payloadKind: PayloadKind = .systemEvent
|
||||
@State private var systemEventText: String = ""
|
||||
@State private var agentMessage: String = ""
|
||||
@State private var deliver: Bool = false
|
||||
@State private var channel: String = "last"
|
||||
@State private var to: String = ""
|
||||
@State private var thinking: String = ""
|
||||
@State private var timeoutSeconds: String = ""
|
||||
@State private var bestEffortDeliver: Bool = false
|
||||
@State private var postToMain: Bool = false
|
||||
@State private var postPrefix: String = "Cron"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Form {
|
||||
Section("Basics") {
|
||||
TextField("Name (optional)", text: self.$name)
|
||||
Toggle("Enabled", isOn: self.$enabled)
|
||||
Picker("Session target", selection: self.$sessionTarget) {
|
||||
Text("main").tag(CronSessionTarget.main)
|
||||
Text("isolated").tag(CronSessionTarget.isolated)
|
||||
}
|
||||
Picker("Wake mode", selection: self.$wakeMode) {
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
Text("now").tag(CronWakeMode.now)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Schedule") {
|
||||
Picker("Kind", selection: self.$scheduleKind) {
|
||||
Text("at").tag(ScheduleKind.at)
|
||||
Text("every").tag(ScheduleKind.every)
|
||||
Text("cron").tag(ScheduleKind.cron)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
DatePicker("At", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
|
||||
case .every:
|
||||
TextField("Every (e.g. 10m, 1h, 1d)", text: self.$everyText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
case .cron:
|
||||
TextField("Cron expr (5-field)", text: self.$cronExpr)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Timezone (optional)", text: self.$cronTz)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Payload") {
|
||||
if self.sessionTarget == .isolated {
|
||||
Text("Isolated jobs always run an agent turn.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.agentTurnEditor
|
||||
} else {
|
||||
Picker("Kind", selection: self.$payloadKind) {
|
||||
Text("systemEvent").tag(PayloadKind.systemEvent)
|
||||
Text("agentTurn").tag(PayloadKind.agentTurn)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
case .agentTurn:
|
||||
self.agentTurnEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.payloadKind == .agentTurn {
|
||||
Section("Post summary to main (optional)") {
|
||||
Toggle("Post to main", isOn: self.$postToMain)
|
||||
if self.postToMain {
|
||||
TextField("Prefix", text: self.$postPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 520)
|
||||
|
||||
if let error, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { self.onCancel() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button {
|
||||
self.save()
|
||||
} label: {
|
||||
if self.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSaving)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.onAppear { self.hydrateFromJob() }
|
||||
.onChange(of: self.sessionTarget) { _, newValue in
|
||||
if newValue == .isolated {
|
||||
self.payloadKind = .agentTurn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var agentTurnEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Agent message", text: self.$agentMessage, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
TextField("Thinking (optional)", text: self.$thinking)
|
||||
TextField("Timeout seconds (optional)", text: self.$timeoutSeconds)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Deliver result", isOn: self.$deliver)
|
||||
if self.deliver {
|
||||
Picker("Channel", selection: self.$channel) {
|
||||
Text("last").tag("last")
|
||||
Text("whatsapp").tag("whatsapp")
|
||||
Text("telegram").tag("telegram")
|
||||
}
|
||||
TextField("To (optional)", text: self.$to)
|
||||
Toggle("Best-effort deliver", isOn: self.$bestEffortDeliver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hydrateFromJob() {
|
||||
guard let job else { return }
|
||||
self.name = job.name ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
case let .at(atMs):
|
||||
self.scheduleKind = .at
|
||||
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
case let .every(everyMs, _):
|
||||
self.scheduleKind = .every
|
||||
self.everyText = self.formatDuration(ms: everyMs)
|
||||
case let .cron(expr, tz):
|
||||
self.scheduleKind = .cron
|
||||
self.cronExpr = expr
|
||||
self.cronTz = tz ?? ""
|
||||
}
|
||||
|
||||
switch job.payload {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = channel ?? "last"
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postToMain = job.isolation?.postToMain ?? false
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
self.error = nil
|
||||
let payload = try self.buildPayload()
|
||||
self.onSave(payload)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPayload() throws -> [String: Any] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if expr.isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !name.isEmpty { root["name"] = name }
|
||||
|
||||
if self.payloadKind == .agentTurn || self.sessionTarget == .isolated {
|
||||
if self.postToMain {
|
||||
root["isolation"] = [
|
||||
"postToMain": true,
|
||||
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix,
|
||||
]
|
||||
} else if self.job != nil {
|
||||
// Allow clearing isolation on edit.
|
||||
root["isolation"] = ["postToMain": false]
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private func buildAgentTurnPayload() -> [String: Any] {
|
||||
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
|
||||
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !thinking.isEmpty { payload["thinking"] = thinking }
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func parseDurationMs(_ input: String) -> Int? {
|
||||
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { return nil }
|
||||
|
||||
let rx = try? NSRegularExpression(pattern: #"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$"#, options: [.caseInsensitive])
|
||||
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
|
||||
return nil
|
||||
}
|
||||
func group(_ idx: Int) -> String {
|
||||
let range = match.range(at: idx)
|
||||
guard let r = Range(range, in: raw) else { return "" }
|
||||
return String(raw[r])
|
||||
}
|
||||
let n = Double(group(1)) ?? 0
|
||||
if !n.isFinite || n <= 0 { return nil }
|
||||
let unit = group(2).lowercased()
|
||||
let factor: Double = switch unit {
|
||||
case "ms": 1
|
||||
case "s": 1000
|
||||
case "m": 60_000
|
||||
case "h": 3_600_000
|
||||
default: 86_400_000
|
||||
}
|
||||
return Int(floor(n * factor))
|
||||
}
|
||||
|
||||
private func formatDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct CronSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
sessionTarget: .isolated,
|
||||
wakeMode: .now,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize inbox",
|
||||
thinking: "low",
|
||||
timeoutSeconds: 600,
|
||||
deliver: true,
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
|
||||
state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)),
|
||||
]
|
||||
store.selectedJobId = "job-1"
|
||||
store.runEntries = [
|
||||
CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil),
|
||||
]
|
||||
return CronSettings(store: store)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,273 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum DebugActions {
|
||||
private static let verboseDefaultsKey = "clawdis.debug.verboseMain"
|
||||
private static let sessionMenuLimit = 12
|
||||
|
||||
@MainActor
|
||||
static func openAgentEventsWindow() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 620, height: 420),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Agent Events"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = NSHostingView(rootView: AgentEventsWindow())
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openLog() {
|
||||
let path = self.pinoLogPath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Log file not found"
|
||||
alert.informativeText = path
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openConfigFolder() {
|
||||
let url = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis", isDirectory: true)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionStore() {
|
||||
let path = self.resolveSessionStorePath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
} else {
|
||||
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
static func sendTestNotification() async {
|
||||
_ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil)
|
||||
}
|
||||
|
||||
static func sendDebugVoice() async -> Result<String, DebugActionError> {
|
||||
let message = """
|
||||
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
|
||||
if you received that.
|
||||
"""
|
||||
let result = await VoiceWakeForwarder.forward(transcript: message)
|
||||
switch result {
|
||||
case .success:
|
||||
return .success("Sent. Await reply.")
|
||||
case let .failure(error):
|
||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return .failure(.message("Send failed: \(detail)"))
|
||||
}
|
||||
}
|
||||
|
||||
static func restartGateway() {
|
||||
Task { @MainActor in
|
||||
switch AppStateStore.shared.connectionMode {
|
||||
case .local:
|
||||
GatewayProcessManager.shared.stop()
|
||||
// Kick the control channel + health check so the UI recovers immediately.
|
||||
await GatewayConnection.shared.shutdown()
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
Task { try? await ControlChannel.shared.configure(mode: .local) }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
|
||||
case .remote:
|
||||
// In remote mode, there is no local gateway to restart. "Restart Gateway" should
|
||||
// reset the SSH control tunnel + reconnect so the menu recovers.
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
await GatewayConnection.shared.shutdown()
|
||||
do {
|
||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
} catch {
|
||||
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func pinoLogPath() -> String {
|
||||
LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func runHealthCheckNow() async {
|
||||
await HealthStore.shared.refresh(onDemand: true)
|
||||
}
|
||||
|
||||
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
|
||||
do {
|
||||
_ = await AgentRPC.shared.setHeartbeatsEnabled(true)
|
||||
await ControlChannel.shared.configure()
|
||||
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
|
||||
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||
return .success(evt)
|
||||
}
|
||||
return .success(nil)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
static var verboseLoggingEnabledMain: Bool {
|
||||
UserDefaults.standard.bool(forKey: self.verboseDefaultsKey)
|
||||
}
|
||||
|
||||
static func toggleVerboseLoggingMain() async -> Bool {
|
||||
let newValue = !self.verboseLoggingEnabledMain
|
||||
UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
|
||||
_ = try? await ControlChannel.shared.request(
|
||||
method: "system-event",
|
||||
params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")])
|
||||
return newValue
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartApp() {
|
||||
let url = Bundle.main.bundleURL
|
||||
let task = Process()
|
||||
// Relaunch shortly after this instance exits so we get a true restart even in debug.
|
||||
task.launchPath = "/bin/sh"
|
||||
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
|
||||
try? task.run()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis/clawdis.json")
|
||||
guard
|
||||
let data = try? Data(contentsOf: configURL),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let inbound = parsed["inbound"] as? [String: Any],
|
||||
let reply = inbound["reply"] as? [String: Any],
|
||||
let session = reply["session"] as? [String: Any],
|
||||
let path = session["store"] as? String,
|
||||
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return defaultPath
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - Sessions (thinking / verbose)
|
||||
|
||||
static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { return [] }
|
||||
return Array(rows.prefix(limit))
|
||||
}
|
||||
|
||||
static func updateSession(
|
||||
key: String,
|
||||
thinking: String?,
|
||||
verbose: String?) async throws
|
||||
{
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let url = URL(fileURLWithPath: store)
|
||||
guard FileManager.default.fileExists(atPath: store) else {
|
||||
throw DebugActionError.message("Session store missing at \(store)")
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
var decoded = try JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
|
||||
var entry = decoded[key] ?? SessionEntryRecord(
|
||||
sessionId: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
|
||||
entry = SessionEntryRecord(
|
||||
sessionId: entry.sessionId,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
systemSent: entry.systemSent,
|
||||
abortedLastRun: entry.abortedLastRun,
|
||||
thinkingLevel: thinking,
|
||||
verboseLevel: verbose,
|
||||
inputTokens: entry.inputTokens,
|
||||
outputTokens: entry.outputTokens,
|
||||
totalTokens: entry.totalTokens,
|
||||
model: entry.model,
|
||||
contextTokens: entry.contextTokens)
|
||||
|
||||
decoded[key] = entry
|
||||
let encoded = try JSONEncoder().encode(decoded)
|
||||
try encoded.write(to: url, options: [.atomic])
|
||||
}
|
||||
|
||||
// MARK: - Port diagnostics
|
||||
|
||||
typealias PortListener = PortGuardian.ReportListener
|
||||
typealias PortReport = PortGuardian.PortReport
|
||||
|
||||
@MainActor
|
||||
static func openChatInBrowser() async {
|
||||
let session = WebChatManager.shared.preferredSessionKey()
|
||||
await WebChatManager.shared.openInBrowser(sessionKey: session)
|
||||
}
|
||||
|
||||
static func checkGatewayPorts() async -> [PortReport] {
|
||||
let mode = CommandResolver.connectionSettings().mode
|
||||
return await PortGuardian.shared.diagnose(mode: mode)
|
||||
}
|
||||
|
||||
static func killProcess(_ pid: Int) async -> Result<Void, DebugActionError> {
|
||||
let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if primary.ok { return .success(()) }
|
||||
let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if force.ok { return .success(()) }
|
||||
let detail = force.message ?? primary.message ?? "kill failed"
|
||||
return .failure(.message(detail))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionStoreInCode() {
|
||||
let path = SessionLoader.defaultStorePath
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/env"
|
||||
proc.arguments = ["code", path]
|
||||
try? proc.run()
|
||||
}
|
||||
}
|
||||
|
||||
enum DebugActionError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .message(text):
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DebugSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
||||
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
|
||||
@State private var modelsCount: Int?
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelsError: String?
|
||||
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||
@ObservedObject private var healthStore = HealthStore.shared
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@State private var debugSendInFlight = false
|
||||
@State private var debugSendStatus: String?
|
||||
@State private var debugSendError: String?
|
||||
@State private var portCheckInFlight = false
|
||||
@State private var portReports: [DebugActions.PortReport] = []
|
||||
@State private var portKillStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
|
||||
@State private var canvasSessionKey: String = "main"
|
||||
@State private var canvasStatus: String?
|
||||
@State private var canvasError: String?
|
||||
@State private var canvasEvalJS: String = "document.title"
|
||||
@State private var canvasEvalResult: String?
|
||||
@State private var canvasSnapshotPath: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Health") {
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
}
|
||||
}
|
||||
LabeledContent("CLI helper") {
|
||||
let loc = CLIInstaller.installedLocation()
|
||||
Text(loc ?? "missing")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
|
||||
}
|
||||
LabeledContent("PID") { Text("\(ProcessInfo.processInfo.processIdentifier)") }
|
||||
LabeledContent("Log file") {
|
||||
Button("Open pino log") { DebugActions.openLog() }
|
||||
.help(DebugActions.pinoLogPath())
|
||||
Text(DebugActions.pinoLogPath())
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
LabeledContent("Diagnostics log") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.")
|
||||
HStack(spacing: 8) {
|
||||
Button("Open folder") {
|
||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Clear") {
|
||||
Task { try? await DiagnosticsFileLog.shared.clear() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text(DiagnosticsFileLog.logFileURL().path)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) }
|
||||
LabeledContent("Gateway status") {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.gatewayManager.status.label)
|
||||
Text("Restarts: \(self.gatewayManager.restartCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle("Only attach to existing gateway (don’t spawn locally)", isOn: self.$attachExistingGatewayOnly)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
|
||||
LabeledContent("URL scheme") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Allow URL scheme (agent)", isOn: self.$deepLinkAgentEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
HStack(spacing: 8) {
|
||||
Text(key)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Button("Copy key") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(key, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Button("Copy sample agent URL") {
|
||||
let msg = "Hello from deep link"
|
||||
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
|
||||
let url = "clawdis://agent?message=\(encoded)&key=\(key)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Gateway stdout/stderr")
|
||||
.font(.caption.weight(.semibold))
|
||||
ScrollView {
|
||||
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Port diagnostics")
|
||||
.font(.caption.weight(.semibold))
|
||||
if self.portCheckInFlight { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button("Check gateway ports") {
|
||||
Task { await self.runPortCheck() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.portCheckInFlight)
|
||||
}
|
||||
if let portKillStatus {
|
||||
Text(portKillStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns 18788/18789 and suggest fixes.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.portReports) { report in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Port \(report.port)")
|
||||
.font(.footnote.weight(.semibold))
|
||||
Text(report.summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(report.listeners) { listener in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(listener.command) (\(listener.pid))")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Kill") {
|
||||
self.requestKill(listener)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text(listener.fullCommand)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clawdis project root")
|
||||
.font(.caption.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path to clawdis repo", text: self.$gatewayRootInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.onSubmit { self.saveRelayRoot() }
|
||||
Button("Save") { self.saveRelayRoot() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Reset") {
|
||||
let def = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdis").path
|
||||
self.gatewayRootInput = def
|
||||
self.saveRelayRoot()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
LabeledContent("Session store") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 340)
|
||||
Button("Save") { self.saveSessionStorePath() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
if let sessionStoreSaveError {
|
||||
Text(sessionStoreSaveError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Used by the CLI session loader; stored in ~/.clawdis/clawdis.json.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent("Model catalog") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.modelCatalogPath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.chooseCatalogFile()
|
||||
} label: {
|
||||
Label("Choose models.generated.ts…", systemImage: "folder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task { await self.reloadModels() }
|
||||
} label: {
|
||||
Label(
|
||||
self.modelsLoading ? "Reloading…" : "Reload models",
|
||||
systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.modelsLoading)
|
||||
}
|
||||
if let modelsError {
|
||||
Text(modelsError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let modelsCount {
|
||||
Text("Loaded \(modelsCount) models")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Used by the Config tab model picker; point at a different build when debugging.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
Button("Send Test Notification") {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Open Agent Events") {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button {
|
||||
Task { await self.sendVoiceDebug() }
|
||||
} label: {
|
||||
Label(
|
||||
self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice",
|
||||
systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.debugSendInFlight)
|
||||
|
||||
if !self.debugSendInFlight {
|
||||
if let debugSendStatus {
|
||||
Text(debugSendStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let debugSendError {
|
||||
Text(debugSendError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text(
|
||||
"""
|
||||
Uses the Voice Wake path: forwards over SSH when configured,
|
||||
otherwise runs locally via rpc.
|
||||
""")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Reveal app in Finder") { self.revealApp() }
|
||||
Button("Restart Gateway") { DebugActions.restartGateway() }
|
||||
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Canvas")
|
||||
.font(.caption.weight(.semibold))
|
||||
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Session", text: self.$canvasSessionKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 160)
|
||||
Button("Show panel") {
|
||||
Task { await self.canvasShow() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Hide panel") {
|
||||
CanvasManager.shared.hideAll()
|
||||
self.canvasStatus = "hidden"
|
||||
self.canvasError = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Write sample page") {
|
||||
Task { await self.canvasWriteSamplePage() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
TextField("Eval JS", text: self.$canvasEvalJS)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: 420)
|
||||
Button("Eval") {
|
||||
Task { await self.canvasEval() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Snapshot") {
|
||||
Task { await self.canvasSnapshot() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let canvasStatus {
|
||||
Text(canvasStatus)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasEvalResult {
|
||||
Text("eval → \(canvasEvalResult)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasSnapshotPath {
|
||||
HStack(spacing: 8) {
|
||||
Text("snapshot → \(canvasSnapshotPath)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared
|
||||
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
if let canvasError {
|
||||
Text(canvasError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Tip: the session directory is returned by “Show panel”.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Icon override") {
|
||||
Picker("Icon override", selection: self.bindingOverride) {
|
||||
ForEach(IconOverrideSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.help(
|
||||
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.reloadModels()
|
||||
self.loadSessionStorePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
title: Text("Kill \(listener.command) (\(listener.pid))?"),
|
||||
message: Text("This process looks expected for the current mode. Kill anyway?"),
|
||||
primaryButton: .destructive(Text("Kill")) {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runPortCheck() async {
|
||||
self.portCheckInFlight = true
|
||||
self.portKillStatus = nil
|
||||
let reports = await DebugActions.checkGatewayPorts()
|
||||
self.portReports = reports
|
||||
self.portCheckInFlight = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func requestKill(_ listener: DebugActions.PortListener) {
|
||||
if listener.expected {
|
||||
self.pendingKill = listener
|
||||
} else {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func killConfirmed(_ pid: Int32) async {
|
||||
let result = await DebugActions.killProcess(Int(pid))
|
||||
switch result {
|
||||
case .success:
|
||||
self.portKillStatus = "Sent kill to \(pid)."
|
||||
await self.runPortCheck()
|
||||
case let .failure(err):
|
||||
self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseCatalogFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select models.generated.ts"
|
||||
let tsType = UTType(filenameExtension: "ts")
|
||||
?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode)
|
||||
?? .item
|
||||
panel.allowedContentTypes = [tsType]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent()
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
self.modelCatalogPath = url.path
|
||||
self.modelCatalogReloadBump += 1
|
||||
Task { await self.reloadModels() }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelsError = nil
|
||||
self.modelCatalogReloadBump += 1
|
||||
defer { self.modelsLoading = false }
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.modelsCount = loaded.count
|
||||
} catch {
|
||||
self.modelsCount = nil
|
||||
self.modelsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func sendVoiceDebug() async {
|
||||
await MainActor.run {
|
||||
self.debugSendInFlight = true
|
||||
self.debugSendError = nil
|
||||
self.debugSendStatus = nil
|
||||
}
|
||||
|
||||
let result = await DebugActions.sendDebugVoice()
|
||||
|
||||
await MainActor.run {
|
||||
self.debugSendInFlight = false
|
||||
switch result {
|
||||
case let .success(message):
|
||||
self.debugSendStatus = message
|
||||
self.debugSendError = nil
|
||||
case let .failure(error):
|
||||
self.debugSendStatus = nil
|
||||
self.debugSendError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealApp() {
|
||||
let url = Bundle.main.bundleURL
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
private func saveRelayRoot() {
|
||||
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
|
||||
}
|
||||
|
||||
private func loadSessionStorePath() {
|
||||
let url = self.configURL()
|
||||
guard
|
||||
let data = try? Data(contentsOf: url),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let inbound = parsed["inbound"] as? [String: Any],
|
||||
let reply = inbound["reply"] as? [String: Any],
|
||||
let session = reply["session"] as? [String: Any],
|
||||
let path = session["store"] as? String
|
||||
else {
|
||||
self.sessionStorePath = SessionLoader.defaultStorePath
|
||||
return
|
||||
}
|
||||
self.sessionStorePath = path
|
||||
}
|
||||
|
||||
private func saveSessionStorePath() {
|
||||
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var root: [String: Any] = [:]
|
||||
let url = self.configURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
root = parsed
|
||||
}
|
||||
|
||||
var inbound = root["inbound"] as? [String: Any] ?? [:]
|
||||
var reply = inbound["reply"] as? [String: Any] ?? [:]
|
||||
var session = reply["session"] as? [String: Any] ?? [:]
|
||||
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
|
||||
reply["session"] = session
|
||||
inbound["reply"] = reply
|
||||
root["inbound"] = inbound
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
self.sessionStoreSaveError = nil
|
||||
} catch {
|
||||
self.sessionStoreSaveError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var bindingOverride: Binding<String> {
|
||||
Binding {
|
||||
self.iconOverrideRaw
|
||||
} set: { newValue in
|
||||
self.iconOverrideRaw = newValue
|
||||
if let selection = IconOverrideSelection(rawValue: newValue) {
|
||||
Task { @MainActor in
|
||||
AppStateStore.shared.iconOverride = selection
|
||||
WorkActivityStore.shared.resolveIconState(override: selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdis")
|
||||
.appendingPathComponent("clawdis.json")
|
||||
}
|
||||
|
||||
// MARK: - Canvas debug actions
|
||||
|
||||
@MainActor
|
||||
private func canvasShow() async {
|
||||
self.canvasError = nil
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
self.canvasStatus = "dir: \(dir)"
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasWriteSamplePage() async {
|
||||
self.canvasError = nil
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false)
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas Debug</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html,body { height:100%; margin:0; background:#0b1020; color:#e5e7eb; }
|
||||
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.wrap { padding:16px; }
|
||||
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); }
|
||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||
button:active { transform: translateY(1px); }
|
||||
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); }
|
||||
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
||||
.box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); }
|
||||
.muted { color: rgba(229,231,235,.7); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="row">
|
||||
<div class="pill">Canvas Debug</div>
|
||||
<div class="pill muted">generated: \(now)</div>
|
||||
<div class="pill muted">userAgent: <span id="ua"></span></div>
|
||||
<button id="btn">Click me</button>
|
||||
<div class="pill">count: <span id="count">0</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="muted">This is a local file served by the WKURLSchemeHandler.</div>
|
||||
<div class="grid">
|
||||
<div class="box"></div><div class="box"></div><div class="box"></div>
|
||||
<div class="box"></div><div class="box"></div><div class="box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('ua').textContent = navigator.userAgent;
|
||||
let n = 0;
|
||||
document.getElementById('btn').addEventListener('click', () => {
|
||||
n++;
|
||||
document.getElementById('count').textContent = String(n);
|
||||
document.title = 'Canvas Debug (' + n + ')';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
try html.write(to: url, atomically: true, encoding: .utf8)
|
||||
self.canvasStatus = "wrote: \(url.path)"
|
||||
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasEval() async {
|
||||
self.canvasError = nil
|
||||
self.canvasEvalResult = nil
|
||||
do {
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let result = try await CanvasManager.shared.eval(
|
||||
sessionKey: session.isEmpty ? "main" : session,
|
||||
javaScript: self.canvasEvalJS)
|
||||
self.canvasEvalResult = result
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasSnapshot() async {
|
||||
self.canvasError = nil
|
||||
self.canvasSnapshotPath = nil
|
||||
do {
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let path = try await CanvasManager.shared.snapshot(
|
||||
sessionKey: session.isEmpty ? "main" : session,
|
||||
outPath: nil)
|
||||
self.canvasSnapshotPath = path
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DebugSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,123 @@
|
||||
import AppKit
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink")
|
||||
|
||||
@MainActor
|
||||
final class DeepLinkHandler {
|
||||
static let shared = DeepLinkHandler()
|
||||
|
||||
private var lastPromptAt: Date = .distantPast
|
||||
|
||||
func handle(url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else {
|
||||
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
|
||||
return
|
||||
}
|
||||
guard UserDefaults.standard.bool(forKey: deepLinkAgentEnabledKey) else {
|
||||
self.presentAlert(
|
||||
title: "Deep links are disabled",
|
||||
message: "Enable “Allow URL scheme (agent)” in Clawdis Debug Settings to accept clawdis:// links.")
|
||||
return
|
||||
}
|
||||
guard !AppStateStore.shared.isPaused else {
|
||||
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
|
||||
return
|
||||
}
|
||||
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgent(link: link, originalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
||||
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if messagePreview.count > 20000 {
|
||||
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
|
||||
return
|
||||
}
|
||||
|
||||
let allowUnattended = link.key == Self.expectedKey()
|
||||
if !allowUnattended {
|
||||
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
|
||||
deepLinkLogger.debug("throttling deep link prompt")
|
||||
return
|
||||
}
|
||||
self.lastPromptAt = Date()
|
||||
|
||||
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
|
||||
let body =
|
||||
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
|
||||
guard self.confirm(title: "Run Clawdis agent?", message: body) else { return }
|
||||
}
|
||||
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
do {
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(messagePreview),
|
||||
"idempotencyKey": AnyCodable(UUID().uuidString),
|
||||
]
|
||||
if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) }
|
||||
if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) }
|
||||
if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) }
|
||||
if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) }
|
||||
if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) }
|
||||
params["deliver"] = AnyCodable(link.deliver)
|
||||
|
||||
_ = try await GatewayConnection.shared.request(method: "agent", params: params)
|
||||
} catch {
|
||||
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
static func currentKey() -> String {
|
||||
self.expectedKey()
|
||||
}
|
||||
|
||||
private static func expectedKey() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
||||
return key
|
||||
}
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
let data = Data(bytes)
|
||||
let key = data
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
defaults.set(key, forKey: deepLinkKeyKey)
|
||||
return key
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private func confirm(title: String, message: String) -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "Run")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
return alert.runModal() == .alertFirstButtonReturn
|
||||
}
|
||||
|
||||
private func presentAlert(title: String, message: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = .informational
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
actor DiagnosticsFileLog {
|
||||
static let shared = DiagnosticsFileLog()
|
||||
|
||||
private let fileName = "diagnostics.jsonl"
|
||||
private let maxBytes: Int64 = 5 * 1024 * 1024
|
||||
private let maxBackups = 5
|
||||
|
||||
struct Record: Codable, Sendable {
|
||||
let ts: String
|
||||
let pid: Int32
|
||||
let category: String
|
||||
let event: String
|
||||
let fields: [String: String]?
|
||||
}
|
||||
|
||||
nonisolated static func isEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
|
||||
nonisolated static func logDirectoryURL() -> URL {
|
||||
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
return library
|
||||
.appendingPathComponent("Logs", isDirectory: true)
|
||||
.appendingPathComponent("Clawdis", isDirectory: true)
|
||||
}
|
||||
|
||||
nonisolated static func logFileURL() -> URL {
|
||||
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
|
||||
}
|
||||
|
||||
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
|
||||
guard Self.isEnabled() else { return }
|
||||
let record = Record(
|
||||
ts: ISO8601DateFormatter().string(from: Date()),
|
||||
pid: ProcessInfo.processInfo.processIdentifier,
|
||||
category: category,
|
||||
event: event,
|
||||
fields: fields)
|
||||
Task { await self.write(record: record) }
|
||||
}
|
||||
|
||||
func clear() throws {
|
||||
let fm = FileManager.default
|
||||
let base = Self.logFileURL()
|
||||
if fm.fileExists(atPath: base.path) {
|
||||
try fm.removeItem(at: base)
|
||||
}
|
||||
for idx in 1...self.maxBackups {
|
||||
let url = self.rotatedURL(index: idx)
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
try fm.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func write(record: Record) {
|
||||
do {
|
||||
try self.ensureDirectory()
|
||||
try self.rotateIfNeeded()
|
||||
try self.append(record: record)
|
||||
} catch {
|
||||
// Best-effort only: never crash or block the app on logging.
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDirectory() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: Self.logDirectoryURL(),
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func append(record: Record) throws {
|
||||
let url = Self.logFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // newline
|
||||
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
fm.createFile(atPath: url.path, contents: nil)
|
||||
}
|
||||
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
}
|
||||
|
||||
private func rotateIfNeeded() throws {
|
||||
let url = Self.logFileURL()
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber
|
||||
else { return }
|
||||
|
||||
if size.int64Value < self.maxBytes { return }
|
||||
|
||||
let fm = FileManager.default
|
||||
|
||||
let oldest = self.rotatedURL(index: self.maxBackups)
|
||||
if fm.fileExists(atPath: oldest.path) {
|
||||
try fm.removeItem(at: oldest)
|
||||
}
|
||||
|
||||
if self.maxBackups > 1 {
|
||||
for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) {
|
||||
let src = self.rotatedURL(index: idx)
|
||||
let dst = self.rotatedURL(index: idx + 1)
|
||||
if fm.fileExists(atPath: src.path) {
|
||||
if fm.fileExists(atPath: dst.path) {
|
||||
try fm.removeItem(at: dst)
|
||||
}
|
||||
try fm.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = self.rotatedURL(index: 1)
|
||||
if fm.fileExists(atPath: first.path) {
|
||||
try fm.removeItem(at: first)
|
||||
}
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
try fm.moveItem(at: url, to: first)
|
||||
}
|
||||
}
|
||||
|
||||
private func rotatedURL(index: Int) -> URL {
|
||||
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
protocol WebSocketTasking: AnyObject {
|
||||
var state: URLSessionTask.State { get }
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
}
|
||||
|
||||
extension URLSessionWebSocketTask: WebSocketTasking {}
|
||||
|
||||
struct WebSocketTaskBox: @unchecked Sendable {
|
||||
let task: any WebSocketTasking
|
||||
|
||||
var state: URLSessionTask.State { self.task.state }
|
||||
|
||||
func resume() { self.task.resume() }
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.task.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
try await self.task.send(message)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await self.task.receive()
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
self.task.receive(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
protocol WebSocketSessioning: AnyObject {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox
|
||||
}
|
||||
|
||||
extension URLSession: WebSocketSessioning {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: self.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSocketSessionBox: @unchecked Sendable {
|
||||
let session: any WebSocketSessioning
|
||||
}
|
||||
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = ClawdisProtocol.AnyCodable
|
||||
|
||||
actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||
private var connected = false
|
||||
private var isConnecting = false
|
||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private let session: WebSocketSessioning
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
private var lastSeq: Int?
|
||||
private var lastTick: Date?
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
Task { [weak self] in
|
||||
await self?.startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
self.shouldReconnect = false
|
||||
self.connected = false
|
||||
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = nil
|
||||
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = nil
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
await self.failPending(NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
}
|
||||
}
|
||||
|
||||
private func startWatchdog() {
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchdogLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func watchdogLoop() async {
|
||||
// Keep nudging reconnect in case exponential backoff stalls.
|
||||
while self.shouldReconnect {
|
||||
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s cadence
|
||||
guard self.shouldReconnect else { return }
|
||||
if self.connected { continue }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
if self.connected, self.task?.state == .running { return }
|
||||
if self.isConnecting {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connectWaiters.append(cont)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
do {
|
||||
try await self.sendConnect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: wrapped)
|
||||
}
|
||||
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
throw wrapped
|
||||
}
|
||||
self.listen()
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: ())
|
||||
}
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
let client: [String: ProtoAnyCodable] = [
|
||||
"name": ProtoAnyCodable(clientName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("app"),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable([] as [String]),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await self.task?.send(.data(data))
|
||||
guard let msg = try await task?.receive() else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||
}
|
||||
try await self.handleConnectResponse(msg, reqId: reqId)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||
}
|
||||
guard case let .res(res) = frame, res.id == reqId else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
|
||||
}
|
||||
let payloadData = try self.encoder.encode(payload)
|
||||
let ok = try decoder.decode(HelloOk.self, from: payloadData)
|
||||
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
|
||||
self.tickIntervalMs = tick
|
||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchTicks()
|
||||
}
|
||||
await self.pushHandler?(.snapshot(ok))
|
||||
return
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
self.task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case let .failure(err):
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
await self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceiveFailure(_ err: Error) async {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
|
||||
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
self.logger.error("gateway decode failed")
|
||||
return
|
||||
}
|
||||
switch frame {
|
||||
case let .res(res):
|
||||
let id = res.id
|
||||
if let waiter = pending.removeValue(forKey: id) {
|
||||
waiter.resume(returning: .res(res))
|
||||
}
|
||||
case let .event(evt):
|
||||
if let seq = evt.seq {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
||||
}
|
||||
self.lastSeq = seq
|
||||
}
|
||||
if evt.event == "tick" { self.lastTick = Date() }
|
||||
await self.pushHandler?(.event(evt))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func watchTicks() async {
|
||||
let tolerance = self.tickIntervalMs * 2
|
||||
while self.connected {
|
||||
try? await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000))
|
||||
guard self.connected else { return }
|
||||
if let last = self.lastTick {
|
||||
let delta = Date().timeIntervalSince(last) * 1000
|
||||
if delta > tolerance {
|
||||
self.logger.error("gateway tick missed; reconnecting")
|
||||
self.connected = false
|
||||
await self.failPending(
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"]))
|
||||
await self.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() async {
|
||||
guard self.shouldReconnect else { return }
|
||||
let delay = self.backoffMs / 1000
|
||||
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard self.shouldReconnect else { return }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: "gateway connect")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data = try self.encoder.encode(frame)
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
let waiter = self.pending.removeValue(forKey: id)
|
||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
if let waiter { waiter.resume(throwing: wrapped) }
|
||||
}
|
||||
}
|
||||
}
|
||||
guard case let .res(res) = response else {
|
||||
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
|
||||
throw NSError(domain: "Gateway", code: 3, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
if let payload = res.payload {
|
||||
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
||||
return try self.encoder.encode(payload)
|
||||
}
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||
return NSError(
|
||||
domain: URLError.errorDomain,
|
||||
code: urlError.errorCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
let ns = error as NSError
|
||||
let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
for (_, waiter) in waiters {
|
||||
waiter.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRequest(id: String, timeoutMs: Double) async {
|
||||
guard let waiter = self.pending.removeValue(forKey: id) else { return }
|
||||
let err = NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"])
|
||||
waiter.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`.
|
||||
@@ -0,0 +1,114 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
|
||||
/// Single, shared Gateway websocket connection for the whole app.
|
||||
///
|
||||
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
|
||||
/// (ControlChannel, AgentRPC, SwiftUI WebChat, etc.).
|
||||
actor GatewayConnection {
|
||||
static let shared = GatewayConnection()
|
||||
|
||||
typealias Config = (url: URL, token: String?)
|
||||
|
||||
private let configProvider: @Sendable () async throws -> Config
|
||||
private let sessionBox: WebSocketSessionBox?
|
||||
|
||||
private var client: GatewayChannelActor?
|
||||
private var configuredURL: URL?
|
||||
private var configuredToken: String?
|
||||
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
|
||||
init(
|
||||
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
|
||||
sessionBox: WebSocketSessionBox? = nil)
|
||||
{
|
||||
self.configProvider = configProvider
|
||||
self.sessionBox = sessionBox
|
||||
}
|
||||
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
guard let client else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
/// Ensure the underlying socket is configured (and replaced if config changed).
|
||||
func refresh() async throws {
|
||||
let cfg = try await self.configProvider()
|
||||
await self.configure(url: cfg.url, token: cfg.token)
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.client = nil
|
||||
self.configuredURL = nil
|
||||
self.configuredToken = nil
|
||||
self.lastSnapshot = nil
|
||||
}
|
||||
|
||||
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||
let id = UUID()
|
||||
let snapshot = self.lastSnapshot
|
||||
let connection = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
if let snapshot {
|
||||
continuation.yield(.snapshot(snapshot))
|
||||
}
|
||||
self.subscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await connection.removeSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ id: UUID) {
|
||||
self.subscribers[id] = nil
|
||||
}
|
||||
|
||||
private func broadcast(_ push: GatewayPush) {
|
||||
if case let .snapshot(snapshot) = push {
|
||||
self.lastSnapshot = snapshot
|
||||
}
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(push)
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token {
|
||||
return
|
||||
}
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.lastSnapshot = nil
|
||||
self.client = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
session: self.sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handle(push: push)
|
||||
})
|
||||
self.configuredURL = url
|
||||
self.configuredToken = token
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
self.broadcast(push)
|
||||
}
|
||||
|
||||
private static func defaultConfigProvider() async throws -> Config {
|
||||
try await GatewayEndpointStore.shared.requireConfig()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
/// Single place to resolve (and publish) the effective gateway control endpoint.
|
||||
///
|
||||
/// This is intentionally separate from `GatewayConnection`:
|
||||
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
|
||||
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
let token: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
|
||||
|
||||
private var state: GatewayEndpointState
|
||||
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
||||
|
||||
init(deps: Deps = .live) {
|
||||
self.deps = deps
|
||||
let port = deps.localPort()
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
|
||||
}
|
||||
|
||||
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
|
||||
let id = UUID()
|
||||
let initial = self.state
|
||||
let store = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
continuation.yield(initial)
|
||||
self.subscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await store.removeSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
let mode = await self.deps.mode()
|
||||
await self.setMode(mode)
|
||||
}
|
||||
|
||||
func setMode(_ mode: AppState.ConnectionMode) async {
|
||||
let token = self.deps.token()
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
||||
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let port = try await self.deps.ensureRemoteTunnel()
|
||||
await self.setMode(.remote)
|
||||
return port
|
||||
}
|
||||
|
||||
func requireConfig() async throws -> GatewayConnection.Config {
|
||||
await self.refresh()
|
||||
switch self.state {
|
||||
case let .ready(_, url, token):
|
||||
return (url, token)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
}
|
||||
|
||||
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||
// recreate it on demand so callers can recover without a manual reconnect.
|
||||
do {
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token))
|
||||
return (url, token)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ id: UUID) {
|
||||
self.subscribers[id] = nil
|
||||
}
|
||||
|
||||
private func setState(_ next: GatewayEndpointState) {
|
||||
guard next != self.state else { return }
|
||||
self.state = next
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(next)
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _):
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import ClawdisIPC
|
||||
import Foundation
|
||||
|
||||
// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
|
||||
struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
||||
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||
|
||||
static func < (lhs: Semver, rhs: Semver) -> Bool {
|
||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||
return lhs.patch < rhs.patch
|
||||
}
|
||||
|
||||
static func parse(_ raw: String?) -> Semver? {
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
||||
let parts = cleaned.split(separator: ".")
|
||||
guard parts.count >= 3,
|
||||
let major = Int(parts[0]),
|
||||
let minor = Int(parts[1])
|
||||
else { return nil }
|
||||
let patch = Int(parts[2]) ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
func compatible(with required: Semver) -> Bool {
|
||||
// Same major and not older than required.
|
||||
self.major == required.major && self >= required
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayEnvironmentKind: Equatable {
|
||||
case checking
|
||||
case ok
|
||||
case missingNode
|
||||
case missingGateway
|
||||
case incompatible(found: String, required: String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
struct GatewayEnvironmentStatus: Equatable {
|
||||
let kind: GatewayEnvironmentKind
|
||||
let nodeVersion: String?
|
||||
let gatewayVersion: String?
|
||||
let requiredGateway: String?
|
||||
let message: String
|
||||
|
||||
static var checking: Self {
|
||||
.init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…")
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayCommandResolution {
|
||||
let status: GatewayEnvironmentStatus
|
||||
let command: [String]?
|
||||
}
|
||||
|
||||
enum GatewayEnvironment {
|
||||
static func gatewayPort() -> Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
return stored > 0 ? stored : 18789
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
return Semver.parse(bundleVersion)
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
|
||||
Semver.parse(versionString)
|
||||
}
|
||||
|
||||
static func check() -> GatewayEnvironmentStatus {
|
||||
let expected = self.expectedGatewayVersion()
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
|
||||
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
|
||||
case let .failure(err):
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdisExecutable()
|
||||
|
||||
if gatewayBin == nil, projectEntrypoint == nil {
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: "clawdis CLI not found in PATH; install the global package.")
|
||||
}
|
||||
|
||||
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: """
|
||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||
install or update the global package.
|
||||
""")
|
||||
}
|
||||
|
||||
let gatewayLabel = gatewayBin != nil ? "global" : "local"
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
// Avoid repeating "(local)" twice; if using the local entrypoint, show the path once.
|
||||
let localPathHint = gatewayBin == nil && projectEntrypoint != nil
|
||||
? " (local: \(projectEntrypoint ?? "unknown"))"
|
||||
: ""
|
||||
let gatewayLabelText = gatewayBin != nil
|
||||
? "(\(gatewayLabel))"
|
||||
: localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveGatewayCommand() -> GatewayCommandResolution {
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
let status = self.check()
|
||||
let gatewayBin = CommandResolver.clawdisExecutable()
|
||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||
|
||||
guard case .ok = status.kind else {
|
||||
return GatewayCommandResolution(status: status, command: nil)
|
||||
}
|
||||
|
||||
let port = self.gatewayPort()
|
||||
if let gatewayBin {
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
if let entry = projectEntrypoint,
|
||||
case let .success(resolvedRuntime) = runtime
|
||||
{
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
return GatewayCommandResolution(status: status, command: nil)
|
||||
}
|
||||
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm"
|
||||
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
|
||||
|
||||
statusHandler("Installing clawdis@\(target) via pnpm…")
|
||||
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||
if response.ok {
|
||||
statusHandler("Installed clawdis@\(target)")
|
||||
} else {
|
||||
let detail = response.message ?? "install failed"
|
||||
statusHandler("Install failed: \(detail)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func readGatewayVersion(binary: String) -> Semver? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = ["--version"]
|
||||
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? {
|
||||
let pkg = projectRoot.appendingPathComponent("package.json")
|
||||
guard let data = try? Data(contentsOf: pkg) else { return nil }
|
||||
guard
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let version = json["version"] as? String
|
||||
else { return nil }
|
||||
return Semver.parse(version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
|
||||
enum GatewayPayloadDecoding {
|
||||
static func decode<T: Decodable>(_ payload: ClawdisProtocol.AnyCodable, as _: T.Type = T.self) throws -> T {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
static func decodeIfPresent<T: Decodable>(_ payload: ClawdisProtocol.AnyCodable?, as _: T.Type = T.self) throws
|
||||
-> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try decode(payload, as: T.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import Subprocess
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
#if canImport(System)
|
||||
import System
|
||||
#else
|
||||
import SystemPackage
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class GatewayProcessManager: ObservableObject {
|
||||
static let shared = GatewayProcessManager()
|
||||
|
||||
enum Status: Equatable {
|
||||
case stopped
|
||||
case starting
|
||||
case running(pid: Int32)
|
||||
case restarting
|
||||
case attachedExisting(details: String?)
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .stopped: return "Stopped"
|
||||
case .starting: return "Starting…"
|
||||
case let .running(pid): return "Running (pid \(pid))"
|
||||
case .restarting: return "Restarting…"
|
||||
case let .attachedExisting(details):
|
||||
if let details, !details.isEmpty {
|
||||
return "Using existing gateway (\(details))"
|
||||
}
|
||||
return "Using existing gateway"
|
||||
case let .failed(reason): return "Failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var status: Status = .stopped
|
||||
@Published private(set) var log: String = ""
|
||||
@Published private(set) var restartCount: Int = 0
|
||||
@Published private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||
@Published private(set) var existingGatewayDetails: String?
|
||||
|
||||
private var execution: Execution?
|
||||
private var lastPid: Int32?
|
||||
private var lastCommand: [String]?
|
||||
private var desiredActive = false
|
||||
private var stopping = false
|
||||
private var recentCrashes: [Date] = []
|
||||
|
||||
private final class GatewayLockHandle {
|
||||
private let fd: FileDescriptor
|
||||
private let path: String
|
||||
|
||||
init(fd: FileDescriptor, path: String) {
|
||||
self.fd = fd
|
||||
self.path = path
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
try? self.fd.close()
|
||||
try? FileManager.default.removeItem(atPath: self.path)
|
||||
}
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway")
|
||||
private let logLimit = 20000 // characters to keep in-memory
|
||||
private let maxCrashes = 3
|
||||
private let crashWindow: TimeInterval = 120 // seconds
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
self.desiredActive = false
|
||||
self.stop()
|
||||
self.status = .stopped
|
||||
self.appendLog("[gateway] remote mode active; skipping local gateway\n")
|
||||
return
|
||||
}
|
||||
self.desiredActive = active
|
||||
self.refreshEnvironmentStatus()
|
||||
if active {
|
||||
self.startIfNeeded()
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
guard self.execution == nil, self.desiredActive else { return }
|
||||
// Do not spawn in remote mode (the gateway should run on the remote host).
|
||||
guard !CommandResolver.connectionModeIsRemote() else {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
if self.shouldGiveUpAfterCrashes() {
|
||||
self.status = .failed("Too many crashes; giving up")
|
||||
return
|
||||
}
|
||||
|
||||
if self.status != .restarting {
|
||||
self.status = .starting
|
||||
}
|
||||
|
||||
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
// Respect debug toggle: only attach, never spawn, when enabled.
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
await MainActor.run {
|
||||
self.status = .stopped
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.spawnGateway()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.desiredActive = false
|
||||
self.stopping = true
|
||||
self.existingGatewayDetails = nil
|
||||
guard let execution else {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
self.status = .stopped
|
||||
Task {
|
||||
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(1))])
|
||||
}
|
||||
self.execution = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus() {
|
||||
self.environmentStatus = GatewayEnvironment.check()
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Attempt to connect to an already-running gateway on the configured port.
|
||||
/// If successful, mark status as attached and skip spawning a new process.
|
||||
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: "health", params: nil)
|
||||
let details: String
|
||||
if let snap = decodeHealthSnapshot(from: data) {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText: String
|
||||
if let instance {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
instanceText = "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
} else {
|
||||
instanceText = "pid unknown"
|
||||
}
|
||||
details = "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
} else {
|
||||
details = "port \(port), health probe succeeded"
|
||||
}
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
return true
|
||||
} catch {
|
||||
// No reachable gateway (or token mismatch) — fall through to spawn.
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func spawnGateway() async {
|
||||
if self.status != .restarting {
|
||||
self.status = .starting
|
||||
}
|
||||
self.existingGatewayDetails = nil
|
||||
let resolution = GatewayEnvironment.resolveGatewayCommand()
|
||||
await MainActor.run { self.environmentStatus = resolution.status }
|
||||
guard let command = resolution.command else {
|
||||
await MainActor.run {
|
||||
self.status = .failed(resolution.status.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let cwd = self.defaultProjectRoot().path
|
||||
self.appendLog("[gateway] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
|
||||
self.lastCommand = command
|
||||
|
||||
do {
|
||||
// Acquire the same UDS lock the CLI uses to guarantee a single instance.
|
||||
let lockPath = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-gateway.lock").path
|
||||
let listener = try self.acquireGatewayLock(path: lockPath)
|
||||
|
||||
let result = try await run(
|
||||
.name(command.first ?? "clawdis"),
|
||||
arguments: Arguments(Array(command.dropFirst())),
|
||||
environment: self.makeEnvironment(),
|
||||
workingDirectory: FilePath(cwd))
|
||||
{ execution, stdin, stdout, stderr in
|
||||
self.didStart(execution)
|
||||
// Consume stdout/stderr eagerly so the gateway can't block on full pipes.
|
||||
async let out: Void = self.stream(output: stdout, label: "stdout")
|
||||
async let err: Void = self.stream(output: stderr, label: "stderr")
|
||||
try await stdin.finish()
|
||||
await out
|
||||
await err
|
||||
}
|
||||
|
||||
// Release the lock after the process exits.
|
||||
listener.cancel()
|
||||
|
||||
await self.handleTermination(status: result.terminationStatus)
|
||||
} catch {
|
||||
await self.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal clone of the Node gateway lock: take an exclusive file lock.
|
||||
private func acquireGatewayLock(path: String) throws -> GatewayLockHandle {
|
||||
// Remove stale lock if needed (mirrors CLI behavior).
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
let fd = try FileDescriptor.open(
|
||||
FilePath(path),
|
||||
.readWrite,
|
||||
options: [.create, .exclusiveCreate],
|
||||
permissions: [.ownerReadWrite])
|
||||
return GatewayLockHandle(fd: fd, path: path)
|
||||
}
|
||||
|
||||
private func didStart(_ execution: Execution) {
|
||||
self.execution = execution
|
||||
self.stopping = false
|
||||
self.status = .running(pid: execution.processIdentifier.value)
|
||||
self.lastPid = execution.processIdentifier.value
|
||||
self.logger.info("gateway started pid \(execution.processIdentifier.value)")
|
||||
Task {
|
||||
await PortGuardian.shared.record(
|
||||
port: GatewayEnvironment.gatewayPort(),
|
||||
pid: execution.processIdentifier.value,
|
||||
command: (self.lastCommand ?? []).joined(separator: " "),
|
||||
mode: AppStateStore.shared.connectionMode)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(status: TerminationStatus) async {
|
||||
let code: Int32 = switch status {
|
||||
case let .exited(exitCode): exitCode
|
||||
case let .unhandledException(sig): -Int32(sig)
|
||||
}
|
||||
|
||||
self.execution = nil
|
||||
if let pid = self.lastPid {
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
self.lastPid = nil
|
||||
self.lastCommand = nil
|
||||
if self.stopping || !self.desiredActive {
|
||||
self.status = .stopped
|
||||
self.stopping = false
|
||||
if let pid = self.lastPid {
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.recentCrashes.append(Date())
|
||||
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
|
||||
self.restartCount += 1
|
||||
self.appendLog("[gateway] exited (\(code)).\n")
|
||||
|
||||
if self.shouldGiveUpAfterCrashes() {
|
||||
self.status = .failed("Too many crashes; stopped auto-restart.")
|
||||
self.logger.error("gateway crash loop detected; giving up")
|
||||
return
|
||||
}
|
||||
|
||||
self.status = .restarting
|
||||
self.logger.warning("gateway crashed (code \(code)); restarting")
|
||||
// Slight backoff to avoid hammering the system in case of immediate crash-on-start.
|
||||
try? await Task.sleep(nanoseconds: 750_000_000)
|
||||
self.startIfNeeded()
|
||||
}
|
||||
|
||||
private func handleError(_ error: any Error) async {
|
||||
self.execution = nil
|
||||
var message = error.localizedDescription
|
||||
if let sp = error as? SubprocessError {
|
||||
message = "SubprocessError \(sp.code.value): \(sp)"
|
||||
}
|
||||
self.appendLog("[gateway] failed: \(message)\n")
|
||||
self.logger.error("gateway failed: \(message, privacy: .public)")
|
||||
if self.desiredActive, !self.shouldGiveUpAfterCrashes() {
|
||||
self.status = .restarting
|
||||
self.recentCrashes.append(Date())
|
||||
self.startIfNeeded()
|
||||
} else {
|
||||
self.status = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldGiveUpAfterCrashes() -> Bool {
|
||||
self.recentCrashes = self.recentCrashes.filter { Date().timeIntervalSince($0) < self.crashWindow }
|
||||
return self.recentCrashes.count >= self.maxCrashes
|
||||
}
|
||||
|
||||
private func stream(output: AsyncBufferSequence, label: String) async {
|
||||
do {
|
||||
for try await line in output.lines() {
|
||||
await MainActor.run {
|
||||
self.appendLog(line + "\n")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.appendLog("[gateway \(label)] stream error: \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLog(_ chunk: String) {
|
||||
self.log.append(chunk)
|
||||
if self.log.count > self.logLimit {
|
||||
self.log = String(self.log.suffix(self.logLimit))
|
||||
}
|
||||
}
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
}
|
||||
|
||||
private func makeEnvironment() -> Environment {
|
||||
let merged = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
return .inherit.updating([
|
||||
"PATH": merged,
|
||||
"PNPM_HOME": FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/pnpm").path,
|
||||
"CLAWDIS_PROJECT_ROOT": CommandResolver.projectRoot().path,
|
||||
])
|
||||
}
|
||||
|
||||
private func defaultProjectRoot() -> URL {
|
||||
CommandResolver.projectRoot()
|
||||
}
|
||||
|
||||
func setProjectRoot(path: String) {
|
||||
CommandResolver.setProjectRoot(path)
|
||||
}
|
||||
|
||||
func projectRootPath() -> String {
|
||||
CommandResolver.projectRootPath()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 4177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user