Compare commits
2047 Commits
v1.2.2
...
v2.0.0-beta4
| Author | SHA1 | Date | |
|---|---|---|---|
| e5cae2a2e4 | |||
| 7f961237f9 | |||
| 69a6538567 | |||
| 5b3c18ab84 | |||
| 907371453d | |||
| 81abffd145 | |||
| 44ef8fe5c8 | |||
| cae78b3f91 | |||
| c0fb814658 | |||
| 7ce0140c81 | |||
| 12b3034921 | |||
| ec482ac867 | |||
| ae52fb7a01 | |||
| e8ff08e121 | |||
| cc8e104cd6 | |||
| 5919a277bb | |||
| 96911d7790 | |||
| acd3f7dba7 | |||
| 8aff3979db | |||
| eafcd862be | |||
| 8826170635 | |||
| c54e4d0900 | |||
| 52ca5c4aa2 | |||
| 95f8f80e74 | |||
| 7e380bb6f8 | |||
| 2477ffd860 | |||
| a3dc46bf9d | |||
| 5c8e1b6eef | |||
| ae9a8ce34c | |||
| 67b9a675f5 | |||
| fae11e5a55 | |||
| 4daf75a469 | |||
| d0293649cd | |||
| 353366ac54 | |||
| 1a8ffebb00 | |||
| 5ffbddcc57 | |||
| 5fbcbe7e52 | |||
| 7daa93cf5a | |||
| 9e32f29d19 | |||
| 1f25e38c2d | |||
| c10a386d17 | |||
| a13db82d28 | |||
| ec392dc870 | |||
| 90d00fb095 | |||
| e336b7f27e | |||
| 7f4c992dd7 | |||
| ba1626a5b9 | |||
| ab73c40bfe | |||
| 4016bc2416 | |||
| 9302daadc1 | |||
| de7429e148 | |||
| 5892bd45d8 | |||
| 9317eccfc8 | |||
| 1236c4dafb | |||
| f50f18f65a | |||
| 747cc4daa5 | |||
| 51b6a785e6 | |||
| f4d41ef254 | |||
| b9d80aa535 | |||
| 2f8213ca9a | |||
| 541b8cbb6c | |||
| ed2e738ea4 | |||
| 17d9ba256b | |||
| 15dbac8193 | |||
| 2119854246 | |||
| 034c93fd65 | |||
| ce91aba4de | |||
| e33c09f8d4 | |||
| a678c3f53e | |||
| 3e4fc7ff7f | |||
| 8dda07a1e9 | |||
| e9f1851c5d | |||
| ac659ff5a7 | |||
| 557f8e5a04 | |||
| 54de5ad3fa | |||
| 0709586e3a | |||
| 82ced33747 | |||
| d31c5d7a2c | |||
| 2045487d5e | |||
| 4611e799b7 | |||
| ffe9a2435b | |||
| f5d8876384 | |||
| d28265cfbe | |||
| 8059e83c49 | |||
| d6f07c9f91 | |||
| 917cb8fa67 | |||
| 461db9e469 | |||
| 112908886c | |||
| f734801da1 | |||
| ea6dc7c710 | |||
| cd81348ca5 | |||
| ad91a09b07 | |||
| 040f73a3f4 | |||
| 0d8e0ddc4f | |||
| 8f9d7405ed | |||
| 72267e97ca | |||
| 19f87f0a89 | |||
| 9f7b1f0942 | |||
| 1ef888ca23 | |||
| 8b815bce94 | |||
| 97539db36d | |||
| 655fa5b8e0 | |||
| 9fbd3cc16f | |||
| 2295cbb815 | |||
| 198f8ea700 | |||
| 9fa9199747 | |||
| 1cd167a59a | |||
| 2868dc975c | |||
| 214ab16eb2 | |||
| 1c88d9575e | |||
| 1e4e02ddd3 | |||
| f6fcddbe0b | |||
| 474180c112 | |||
| c860573f13 | |||
| c9c7354009 | |||
| 42eb7640f9 | |||
| aafcd569b1 | |||
| b549307ccf | |||
| 57090d4f8d | |||
| 764f7586de | |||
| d96f2abc4e | |||
| 92f467e81c | |||
| 2442186a31 | |||
| 9fb74cb58a | |||
| 81e11c1d91 | |||
| dc93350e0a | |||
| 3c6432da1f | |||
| 4eecb6841a | |||
| 3b83d3ff3a | |||
| 88b92a9605 | |||
| 3bb5baa6d2 | |||
| 59443d7ec6 | |||
| c1d170e13d | |||
| cffac6e11a | |||
| 79870472e1 | |||
| 1b69c94f76 | |||
| cf8d1cf0e7 | |||
| 009fbeb543 | |||
| 9ceb8731d3 | |||
| 8f934bf817 | |||
| 88be2701f4 | |||
| 8ee62f0ac8 | |||
| 4d4308af78 | |||
| f7c5eff35e | |||
| 3bc1644f34 | |||
| 27025b71db | |||
| 523d9ec3c2 | |||
| aeb5455555 | |||
| 337390b590 | |||
| 836d950e05 | |||
| ad096f77fc | |||
| 3774494f7e | |||
| 14fae5af9e | |||
| 65b48561a9 | |||
| 842dc14c18 | |||
| af1afa7ba6 | |||
| 8c4c5e524b | |||
| 204bd7d2c4 | |||
| f44014ff00 | |||
| 01719b02e2 | |||
| 4ba86bbe00 | |||
| b85503b3b2 | |||
| 131a9aa1ac | |||
| bd223606b1 | |||
| f4fb80e523 | |||
| 49e466dd40 | |||
| deec315f6a | |||
| 7fafe54e16 | |||
| bdcbc829a0 | |||
| 4a64e86ecb | |||
| 1e2946ebc6 | |||
| 1ed5ca3fde | |||
| aa62ac4042 | |||
| e8f24910bd | |||
| 8d34e54dc5 | |||
| c5ede3f167 | |||
| 1cd108e891 | |||
| 8878fd3028 | |||
| a22d4e7962 | |||
| 25d2d7389f | |||
| 816b784399 | |||
| c250f092bb | |||
| b9c2bdf641 | |||
| 5ba90db049 | |||
| 88d20c5419 | |||
| e158bee95f | |||
| 0139a77e94 | |||
| e76d1b899b | |||
| 3fcdd6c9d7 | |||
| bc916dbf35 | |||
| 96da2efb13 | |||
| 267cdf20e1 | |||
| 20c7df35c4 | |||
| 0f06e9926b | |||
| 93af424ce5 | |||
| 5e07400cd1 | |||
| 364a6a9444 | |||
| b6bfd8e34f | |||
| b05981ef27 | |||
| 42f1a56832 | |||
| f667d56701 | |||
| df5284beaf | |||
| 6d551b0d6e | |||
| 25e6339e2e | |||
| f70fd30cd3 | |||
| 863d26558a | |||
| cba12a1abd | |||
| 96d57a18ee | |||
| e54ed10bc1 | |||
| c8c807adcc | |||
| cd6ed79433 | |||
| ea4b3b74bb | |||
| facfd64787 | |||
| 760a83d256 | |||
| bbff19698b | |||
| 6f38cb162c | |||
| af82224f82 | |||
| a938e9473b | |||
| 3e88553d52 | |||
| 56245d5646 | |||
| 4af08b1606 | |||
| fc4a395c88 | |||
| de1813ab32 | |||
| 89ace66972 | |||
| 63f1857bda | |||
| 279500cba4 | |||
| 183270b443 | |||
| a5f4332f21 | |||
| 6fad79f581 | |||
| dff6274a93 | |||
| 082c872469 | |||
| 67a3dda53a | |||
| 950432eac0 | |||
| 6550e7d562 | |||
| ffe75f3e20 | |||
| 8431874b15 | |||
| 54d2ccda99 | |||
| 926b6d9464 | |||
| abfb6832c3 | |||
| ceeea359fc | |||
| ef35868bef | |||
| cf48d297dd | |||
| 2b20e3d2b0 | |||
| 918cbdcf03 | |||
| f5837dff9c | |||
| ce04308c17 | |||
| c0c20ebf3e | |||
| 823195a122 | |||
| 581583abb4 | |||
| 882fd48408 | |||
| 91238df13f | |||
| ca806897c2 | |||
| 9118884e92 | |||
| e403f8b620 | |||
| 6205b955da | |||
| d265a04b19 | |||
| afc09744b4 | |||
| 1e1d76d600 | |||
| 0b70aa0c56 | |||
| 4ca6591045 | |||
| 9717f2d374 | |||
| 469c8a1a4b | |||
| 9d47b15575 | |||
| a11a204b8e | |||
| e3c3d108fe | |||
| 8cadb5cf18 | |||
| f10c8f2b4c | |||
| 5d2d701e1e | |||
| f24d8473b1 | |||
| 3412ff7003 | |||
| 15e468f5dd | |||
| a0dd504991 | |||
| 19b847b23b | |||
| 3b134c8fef | |||
| c872f37aae | |||
| 3ce5b9b0d9 | |||
| 2d7c5f8c53 | |||
| 79c0fd27a0 | |||
| b06d1ed072 | |||
| 52e7a4456a | |||
| f1202ff152 | |||
| e4db7cbd2b | |||
| ff63204d17 | |||
| 4f3a3e93a9 | |||
| b56d4b90ce | |||
| 6c2f9b3150 | |||
| a808cdce13 | |||
| a8629e1855 | |||
| 0146784e18 | |||
| 249b85af1e | |||
| efc12ab28d | |||
| 5b2e7d4464 | |||
| bcd3c13e2c | |||
| 7932e966db | |||
| 30d84643db | |||
| 264c91e620 | |||
| db89be4106 | |||
| 85816a5ee2 | |||
| 5449e44381 | |||
| 20630b8744 | |||
| 3b63d1cb77 | |||
| 5703b9e737 | |||
| 02787b5674 | |||
| 4021da524c | |||
| 5adec0eae0 | |||
| 3f44f0b753 | |||
| 2a975f751b | |||
| 03bd049291 | |||
| 6ddd36666e | |||
| 3791db006e | |||
| 6bf8c0c17a | |||
| 80e1934f4e | |||
| 7415fdb79b | |||
| b850b0dacf | |||
| 04e3d0c2fe | |||
| 3810519671 | |||
| a08c8ef1fa | |||
| 6496a288b8 | |||
| 9f72eb3374 | |||
| e71c71c6c2 | |||
| 0197fb35fe | |||
| bcc5891e03 | |||
| f90ab3c4c2 | |||
| 79280f3d93 | |||
| ce79d0b9a4 | |||
| a5b4a01594 | |||
| 5b25eeb449 | |||
| fb259e8a50 | |||
| b82dfe08a2 | |||
| 4671c9e672 | |||
| 00cdcd4d28 | |||
| 4e1fe88195 | |||
| 28ad475ab4 | |||
| 104e265633 | |||
| 382d237a60 | |||
| de2fd659ab | |||
| d2fda411f3 | |||
| e02944c323 | |||
| a01f4998c5 | |||
| aa198594fd | |||
| 406a94bf76 | |||
| fef1841fee | |||
| 1cb85fdea8 | |||
| 78263e81f1 | |||
| 053c8d5731 | |||
| d69064f364 | |||
| fedb24caf1 | |||
| 6ff8371254 | |||
| 7b6eaa819e | |||
| e94aa296e2 | |||
| 98891103d0 | |||
| 383097a03a | |||
| 2b2f13ca79 | |||
| 78159a9435 | |||
| b4af7b919e | |||
| 65056915d3 | |||
| bc3f744e45 | |||
| fb8da15b01 | |||
| 62f624b66b | |||
| ef20053e72 | |||
| aae68e4f82 | |||
| 1d715d7b1b | |||
| 1d7110ea8f | |||
| 80f70a58e3 | |||
| f7aabeba04 | |||
| 02f6cac9d6 | |||
| df54fc6098 | |||
| fe0fb8d296 | |||
| 591120a7f7 | |||
| 878f074494 | |||
| c1050da852 | |||
| 873daf079c | |||
| df9e4bdd63 | |||
| 43ba1671f1 | |||
| ce4b68d5fb | |||
| 8c18dd40a3 | |||
| e3015bbfb7 | |||
| 817abd8b5f | |||
| dbc9b00de5 | |||
| b635e83651 | |||
| 7aeacdcc6c | |||
| 16e4a0c4bd | |||
| d613800516 | |||
| 94b89216f7 | |||
| 153e09120a | |||
| 238c0c1b86 | |||
| 98ff213708 | |||
| 8a2a07eddb | |||
| 9076d543f3 | |||
| cd77dc9563 | |||
| 9ccf80848d | |||
| 78cb565dc2 | |||
| 6a30452b4a | |||
| e53442d983 | |||
| bc079b29c3 | |||
| cd6addd742 | |||
| 12d6e1cddd | |||
| 28e5ebd72b | |||
| e8106109e3 | |||
| c71d5a8a77 | |||
| d1d27a0bd6 | |||
| ebb7428479 | |||
| 3163a42f36 | |||
| 35a25c3dc2 | |||
| f34f374179 | |||
| aa330350fc | |||
| a2cf1f98d9 | |||
| f84def1b60 | |||
| 91d4c24078 | |||
| 8fe0b72a04 | |||
| 2bcdf741f9 | |||
| 9ae73e87eb | |||
| 77582ff5d4 | |||
| 52a2dfe08b | |||
| 09d2165d36 | |||
| fb9c1f7e65 | |||
| abf05af474 | |||
| 714ba2a58d | |||
| 405ff0377a | |||
| 8421ef7b4a | |||
| fd151c4fc6 | |||
| b36b20d246 | |||
| 44ffe41775 | |||
| 2ca7c2629c | |||
| 483c0e4cea | |||
| 7d51bf0eb0 | |||
| ab4457e2a3 | |||
| 1eb6d617f5 | |||
| 21ac34bc6a | |||
| c050a82c3a | |||
| 750408d0a2 | |||
| a44a313f77 | |||
| d159602928 | |||
| 50e817f193 | |||
| 929a10e33d | |||
| c38aeb1081 | |||
| 35e0894655 | |||
| 943f0d475f | |||
| 96cbab2b22 | |||
| 36c85a617a | |||
| 1356498ee1 | |||
| 49ec53f4ae | |||
| 5687a03f0b | |||
| cdb2a0736a | |||
| cfd3efb6e7 | |||
| 8ec0d813c0 | |||
| ea5333e5f7 | |||
| b13723d3d7 | |||
| 03a4e0c837 | |||
| f49c20c508 | |||
| d3821123ee | |||
| 759ab8acbc | |||
| 7a88071a16 | |||
| f3c4d1a181 | |||
| 4e491757ef | |||
| 5936ed7941 | |||
| 6b56f7d643 | |||
| e618a21f4e | |||
| 0f271ab535 | |||
| 4c054917ef | |||
| b9eabe532e | |||
| 4ee292a952 | |||
| adc2900aff | |||
| 9c801e9c08 | |||
| ba0791b896 | |||
| c4a67b7d02 | |||
| bd572c775d | |||
| 65329496a7 | |||
| 2288ec7384 | |||
| 80b3b9e00c | |||
| 3876c1679a | |||
| ba85f4a62a | |||
| a1b34ef0ef | |||
| f03d2d1b33 | |||
| c7048973bb | |||
| e800e84a77 | |||
| d306fcb8a2 | |||
| 44339a6447 | |||
| 675aadc6a9 | |||
| d95c09d94a | |||
| f508fd3fa2 | |||
| cf96ad8ef9 | |||
| 066a2828c4 | |||
| b6c11154ae | |||
| 6ca897e055 | |||
| 23ffa1905a | |||
| a88e5968ae | |||
| 4abaf62783 | |||
| 9bf5b92d8f | |||
| 044f525eb8 | |||
| 554d9bc6ce | |||
| 49654803aa | |||
| 44c951e432 | |||
| e1b8c30163 | |||
| 70faa4ff36 | |||
| 63b63cd66d | |||
| 137980b46e | |||
| 055d839fc3 | |||
| 3e39dd49aa | |||
| 082b4fb193 | |||
| de1f119a7d | |||
| 7ce12863b8 | |||
| 1ab69948a5 | |||
| 13298d84ea | |||
| c2c5b28c70 | |||
| 6e200ed1c0 | |||
| 3fadbb29a1 | |||
| 6e4eef4a49 | |||
| 8feb09aa89 | |||
| e1a3bab7e5 | |||
| e0cd5650c5 | |||
| 80c09f0845 | |||
| 1f831c6037 | |||
| cc0075e988 | |||
| 4b44a75bc1 | |||
| f46beec20d | |||
| 973bf67683 | |||
| ff6a918e7e | |||
| 5ef2666127 | |||
| ed001a5f55 | |||
| 13ebbd1a2b | |||
| ca8e556619 | |||
| 8900c84155 | |||
| 002d927874 | |||
| cef5bf2768 | |||
| 529543b36d | |||
| 636e4d38d5 | |||
| 2d8e11b78b | |||
| 0e2993a6c8 | |||
| f0ebad3f21 | |||
| a02adcc2ef | |||
| d1850aaada | |||
| cf21a15e06 | |||
| 13124542cf | |||
| cd5809d11f | |||
| 28938ddb32 | |||
| 3c551fd36f | |||
| 94c495c8ed | |||
| f54c801bd2 | |||
| 429972b5c5 | |||
| 9b8a4d0c76 | |||
| 235f3ce0ba | |||
| 06806a1ea1 | |||
| b1a85d89d2 | |||
| 6fc30962d6 | |||
| 849446ae17 | |||
| 479720c169 | |||
| 0e94c6b025 | |||
| 1a51257b71 | |||
| 4e74ba996d | |||
| 80a87e5f9e | |||
| a526d3c1f2 | |||
| d67bec0740 | |||
| b2e11c504b | |||
| 1b38ee8b46 | |||
| afa4a234f9 | |||
| 46b9006de2 | |||
| d54ecc3961 | |||
| fa54950d2e | |||
| 0ac7a93c28 | |||
| bc2a66da32 | |||
| bcced90f11 | |||
| eb076165d2 | |||
| 9248919b05 | |||
| 5472589ddd | |||
| 19f5183176 | |||
| beb6e25ef0 | |||
| 0ad49c25aa | |||
| d46823333d | |||
| 836f645621 | |||
| 96be450cbb | |||
| 56cb415509 | |||
| 2ef2136c2c | |||
| 0b16b4481a | |||
| 0b18f1b948 | |||
| a4d4a30a6b | |||
| 98bbc73925 | |||
| bb7f4abd4b | |||
| bd63b5a231 | |||
| 590f3d0e8f | |||
| 6cbfa01176 | |||
| f929e1b105 | |||
| 77104395ce | |||
| c0d5853c63 | |||
| 5bbf5105f1 | |||
| 5b193d014e | |||
| fc7a63a4de | |||
| aec1869d32 | |||
| 377169959d | |||
| ba497ce57d | |||
| 5e7d12fefa | |||
| a019d3cd83 | |||
| 8c6a592523 | |||
| 47a1774dc0 | |||
| 2bc0c57f18 | |||
| f0705a928a | |||
| 22f9322905 | |||
| 6795e78edf | |||
| 31620fea3a | |||
| 6b6f2b5414 | |||
| c498348a34 | |||
| 00fc731d64 | |||
| d80d112e09 | |||
| 65d723d53c | |||
| fb3fae43c0 | |||
| 41108f497b | |||
| beefda7f60 | |||
| 74cdc1cf3e | |||
| 7f3be083c1 | |||
| b8012a2281 | |||
| 95ea67de28 | |||
| 1fbd84da39 | |||
| beb5b1ad58 | |||
| 77a67484ea | |||
| 0b4e70e38b | |||
| 8f0b5d2d97 | |||
| 0e3e4f269d | |||
| d6c5ee86c5 | |||
| 3772a29557 | |||
| 7831e0040e | |||
| 3780f3152c | |||
| 3146f8bdbc | |||
| 256080e2a2 | |||
| 47510e2912 | |||
| 0c06276b48 | |||
| d66d5cc17e | |||
| df0c51a63b | |||
| c34da133f6 | |||
| f237222bc9 | |||
| 2a4ccaf993 | |||
| ac50a14b6a | |||
| 06f71d883c | |||
| 9ace6af3df | |||
| 9062f60e3d | |||
| 2307756892 | |||
| 7008493f03 | |||
| b5a89e8907 | |||
| ae58838cc5 | |||
| 9a4fc3e086 | |||
| 0241f1a29c | |||
| 801e44f4eb | |||
| 856ce06fda | |||
| d406d3a058 | |||
| 0b8e8144af | |||
| ad26026802 | |||
| c2b8f9a7c3 | |||
| ba79977f07 | |||
| 16e2193911 | |||
| bb5d26ba9e | |||
| 59f9073e21 | |||
| 982f85bf90 | |||
| acdf70e928 | |||
| d182f7e4b2 | |||
| 790079c3b6 | |||
| dda6d7f9e1 | |||
| 256f0fc765 | |||
| e1f320276e | |||
| c61bd6c84d | |||
| 8a343aedf2 | |||
| cd729e83b6 | |||
| cfb36525ab | |||
| 6f58a9d643 | |||
| 0913329b03 | |||
| 402b04a68c | |||
| 4a68b4add4 | |||
| a74c4db948 | |||
| 0fc5ccb76c | |||
| 98a745b3df | |||
| 24009ed00f | |||
| fceab511b3 | |||
| c6421136f9 | |||
| 2f8b75d86e | |||
| 97ec5d52c3 | |||
| 89fcb40557 | |||
| 5c705ab675 | |||
| 2f21b94a76 | |||
| 6f1ae147da | |||
| f2d503ad04 | |||
| 57ee34839d | |||
| 82d8526732 | |||
| 742027a447 | |||
| efed2ae30f | |||
| 54830e8401 | |||
| ce1a8d70d9 | |||
| cd719a8c85 | |||
| 3df53836ca | |||
| 7bb058215d | |||
| 272015c701 | |||
| 21a27e3b65 | |||
| 22516437b7 | |||
| ea53f1bec7 | |||
| 33bf5cf42a | |||
| c976799f8c | |||
| f973b9e0e5 | |||
| 60321352aa | |||
| 6d60224c93 | |||
| 2b2434d239 | |||
| f8bea661fc | |||
| 86225d0eb6 | |||
| 3351c972e7 | |||
| 460e170f7a | |||
| 1a2d39bdf9 | |||
| 99325040f8 | |||
| 568fcbda54 | |||
| f4b186a9d3 | |||
| d862ae17eb | |||
| 9f73131621 | |||
| 99310a5bbb | |||
| 1673bf2d44 | |||
| 4c656ea22f | |||
| 7707e3d887 | |||
| ba204d0330 | |||
| cbb327227a | |||
| 14fa2f47f5 | |||
| 579da8cc9b | |||
| 5693d7d733 | |||
| 07c8fdffd1 | |||
| d3f4db649f | |||
| abbe237cc0 | |||
| ac4a65ddfd | |||
| 693215723a | |||
| 5f0e474be1 | |||
| 0e201c4c18 | |||
| d12ca22b19 | |||
| c7b80c28a1 | |||
| 5c2288218f | |||
| 0844fa38a8 | |||
| 3ed33c5856 | |||
| b3e466ccb6 | |||
| 875cf9a054 | |||
| ca85d217ec | |||
| 6652b1f4f3 | |||
| 9fe04f5659 | |||
| 5b9e51bfaa | |||
| cdea744725 | |||
| 44365f2e27 | |||
| 888dbd7d11 | |||
| 76ddfc4a9e | |||
| 7950a646c3 | |||
| 09819f8b2e | |||
| 69daa24869 | |||
| 35214b6dec | |||
| fe6bf6966b | |||
| e0276ed4b4 | |||
| fce487669b | |||
| e6ba373d08 | |||
| d4b3d504e4 | |||
| 2b2376d4c0 | |||
| 51bdf01e2e | |||
| 9d29fbbf80 | |||
| a40fc50e5e | |||
| df4e4534f4 | |||
| fca6e466b1 | |||
| 0321174519 | |||
| c452f8c430 | |||
| 079c1d8786 | |||
| 0677567cdd | |||
| 7fe7c30b17 | |||
| cc1d8060c4 | |||
| 428a82e734 | |||
| cc235fc312 | |||
| 249f97d1ed | |||
| 3e9310d6cd | |||
| 9051c5891e | |||
| 56d94e6974 | |||
| e6a96bea47 | |||
| cf82e37c36 | |||
| 4fb3e0500a | |||
| 9c7d51429e | |||
| c1985443fd | |||
| 17a27fd312 | |||
| 557ffdbe35 | |||
| e9bfe34850 | |||
| 1a4540d386 | |||
| a0c4b1e061 | |||
| e275ba8d2e | |||
| db7eeee07b | |||
| 84d5f24f5f | |||
| 42948b70e3 | |||
| 28d3bd03b2 | |||
| 6148f862b9 | |||
| 0a32610b37 | |||
| 514759bde7 | |||
| 2eb27ffb4a | |||
| 2ce24fdbf8 | |||
| e9ae10e569 | |||
| a1940418fb | |||
| 6fdc62c008 | |||
| 5e5cb7a292 | |||
| f5ab3e41c5 | |||
| 036bdde764 | |||
| 691bf85d7e | |||
| 4482965d80 | |||
| fdca8fb592 | |||
| c7c32210e6 | |||
| 316a04f606 | |||
| c4da2afb22 | |||
| 9eaa45a291 | |||
| 81a9439eb2 | |||
| be9b550209 | |||
| 6653813cb9 | |||
| cf1278295d | |||
| cdb5ddb2da | |||
| 1cdebb68a0 | |||
| fece42ce0a | |||
| c5867b2876 | |||
| 43e257e7de | |||
| 9dcdeb15ec | |||
| 060a209ecb | |||
| e1e3da946f | |||
| 49a9f74753 | |||
| 74b19843ae | |||
| d691e28675 | |||
| 2a5f0d6063 | |||
| 66a0813e44 | |||
| 64d6d25d65 | |||
| b443c20cef | |||
| 5e8c8367f3 | |||
| 2b0f846f1b | |||
| e7713a28ae | |||
| 7948d071e0 | |||
| fb23717102 | |||
| 3d959c46d0 | |||
| 4cdd61eb78 | |||
| 6d08d84011 | |||
| f6cafd1a15 | |||
| 5792887883 | |||
| e82ee731bf | |||
| 5e09aae4ca | |||
| 740f7b0fb6 | |||
| 7510a6f66a | |||
| 1ff7d458a5 | |||
| c3528fb201 | |||
| 3f5dff35f8 | |||
| 08bfe2b263 | |||
| 42645a7e0a | |||
| 7d4c8ef6b2 | |||
| a1d7b8db6f | |||
| 4a3a4558e2 | |||
| 1b83fc85cd | |||
| c1a10b6056 | |||
| 841a9b4c8a | |||
| f3db02018f | |||
| 4cbaee59cd | |||
| 0d10aa4098 | |||
| f3f8aa5397 | |||
| 4970af6bb9 | |||
| a48aebc78c | |||
| 26bbddde8f | |||
| b48a556de5 | |||
| aab5c490dc | |||
| d54cc49d66 | |||
| 0cef22ef83 | |||
| 7b2f712e20 | |||
| 1a92127dfa | |||
| 26a05292b9 | |||
| caaa79bb76 | |||
| b80c0d85e0 | |||
| 0641281cfe | |||
| f414853d70 | |||
| 7c677c5057 | |||
| 969c7d1c8e | |||
| b202480a66 | |||
| 9e80764c2b | |||
| f5a5320f8f | |||
| 7389fc0e25 | |||
| ce915d3438 | |||
| 3ef910d23e | |||
| 845b26a73b | |||
| e0545e2f94 | |||
| 01341d983c | |||
| 0d68e10dd7 | |||
| e6a60c0dc5 | |||
| 7dbd5acbb1 | |||
| 7a87f3cfb8 | |||
| b817225fb8 | |||
| a097c848bb | |||
| a47d3e3e35 | |||
| 4d4bcaab1e | |||
| 265a3dff27 | |||
| 97fe3972c8 | |||
| 7c91ce2fa7 | |||
| 951993db17 | |||
| 357a1a982b | |||
| f6f69b408f | |||
| 98399b85e3 | |||
| 38a773f245 | |||
| e9e2e5026c | |||
| 8649de6199 | |||
| 3885a2a20f | |||
| dde9fddae4 | |||
| 3a08e6df9d | |||
| f427bec31c | |||
| 67e0739bec | |||
| c7022cc139 | |||
| 65a0de8979 | |||
| d0134722af | |||
| efc7181aa0 | |||
| 3729d269d0 | |||
| 7dd8a7f2e3 | |||
| 56bbcfc3ee | |||
| eec6212cdf | |||
| a5b3b8743a | |||
| 4dd9072a2b | |||
| 073285409b | |||
| 41da61dd6a | |||
| 35e8dae939 | |||
| 05e77b69c4 | |||
| 745eefe0be | |||
| d7165b4720 | |||
| 7b1163f75c | |||
| 507f5623f4 | |||
| 5ace7c9c66 | |||
| 3b35b762cb | |||
| dbd3865e3b | |||
| 6bf1e6fa06 | |||
| 1c0170554e | |||
| 1d79254053 | |||
| 974ab5a8dd | |||
| eaebf4b896 | |||
| cf747e1b82 | |||
| 455fe15bd1 | |||
| c4d0eb9350 | |||
| 10d95348b1 | |||
| f86b1cf6a1 | |||
| 259e9cfccf | |||
| 7318b20f55 | |||
| 322a36f365 | |||
| b8b20eac6d | |||
| 1fb123d701 | |||
| 138f4bd850 | |||
| 20abf31093 | |||
| 4abc551f9e | |||
| 67707763f7 | |||
| df8915cf5c | |||
| a1d16c61ec | |||
| 64b5eb8279 | |||
| c66122c255 | |||
| b792175ec5 | |||
| b944bee121 | |||
| 88ff2f79d5 | |||
| c3fa1fb736 | |||
| e9eb9edc23 | |||
| e8018d8008 | |||
| 694a10f604 | |||
| b2378c01ea | |||
| e2451484d9 | |||
| dccdc950bf | |||
| dd7be2bfd8 | |||
| 66b05163e3 | |||
| 7789bf6907 | |||
| 8b6abe0151 | |||
| 25eb40ab31 | |||
| 0336c1fa37 | |||
| 09541de076 | |||
| 2583fb66cc | |||
| 037ea92679 | |||
| 38f65e7053 | |||
| ebbc416d4b | |||
| 13c4f8da2b | |||
| 099b8c9fa5 | |||
| 1638d32e1c | |||
| 13e1c93c74 | |||
| affbd48a3f | |||
| 00f83ca7af | |||
| 441bd25f90 | |||
| 128df57005 | |||
| a80cd26341 | |||
| 700212608a | |||
| 8fb064ed70 | |||
| a92eb1f33d | |||
| 2454e67e09 | |||
| 862a490038 | |||
| ffc57d5f20 | |||
| e96654ced1 | |||
| dd763b45e1 | |||
| 2710841801 | |||
| a9e1eabcbd | |||
| 30a2e47390 | |||
| f7076c38ea | |||
| e6d522493b | |||
| c286573f5c | |||
| aef18b7359 | |||
| 17e183f5cf | |||
| a53d8ed4e4 | |||
| 755e329b01 | |||
| 765c466d6d | |||
| cf3becfb2e | |||
| b508f642b2 | |||
| 3fcee21ff7 | |||
| b01cb41950 | |||
| 7642cbb5b7 | |||
| 7a6334d920 | |||
| d96bc38bea | |||
| a31a569d52 | |||
| 0d3aacd316 | |||
| ece8a3e701 | |||
| ceb3980b93 | |||
| 01eba1b8d9 | |||
| cf28ea0d1c | |||
| 41dd3b11b7 | |||
| 5a1687484c | |||
| 6143338116 | |||
| 39c232548c | |||
| e2a93e17f9 | |||
| 0b990443de | |||
| 02fe19effa | |||
| 920cc9ac38 | |||
| ba22890205 | |||
| a59cfa7670 | |||
| 7cdd7c5333 | |||
| e3379b960e | |||
| 7b675864a8 | |||
| 3b853b329f | |||
| 537c515dde | |||
| 238afbc2f8 | |||
| 2a71c20ee4 | |||
| 40c66b1741 | |||
| 94ad808028 | |||
| 0c8b5ed59a | |||
| 56fe23549c | |||
| 867d7e5d25 | |||
| a0cd761c96 | |||
| 7c3502f031 | |||
| 61ab07ced3 | |||
| 281c6d6069 | |||
| 82634dfe3b | |||
| 9be3394bac | |||
| 4228ee326c | |||
| fa1110e4d3 | |||
| 050c47d3a7 | |||
| 161895ed1a | |||
| aeffdc3632 | |||
| ecf0da1796 | |||
| 990fafa988 | |||
| ceb0a8b3e3 | |||
| 3b283f3167 | |||
| 2b29d08064 | |||
| 86ed3de1c1 | |||
| acf035d848 | |||
| cab71c9711 | |||
| c17440f5b4 | |||
| fd566bda14 | |||
| e47dccbe87 | |||
| 2a172f9779 | |||
| ce630a6381 | |||
| a882798143 | |||
| 44f9327087 | |||
| e654676148 | |||
| 840e266b5d | |||
| 7d89fa2591 | |||
| 5f67c023a2 | |||
| af3e5b299c | |||
| b3b4013637 | |||
| 9ad341d668 | |||
| d7a8d9a1c7 | |||
| 2d36ae6326 | |||
| 208ba02a4a | |||
| 4cdb21c5cd | |||
| 5d6cc8125b | |||
| 237933069e | |||
| 99660db73f | |||
| 68fa676cbf | |||
| 8794f002d5 | |||
| d52ef185b1 | |||
| 3ca77c46c7 | |||
| 89d5d807ee | |||
| 9e3427a37e | |||
| 7ce25ecfca | |||
| 1ca77bee26 | |||
| 5dbc7cc68d | |||
| 0d45c78917 | |||
| 31fb4f7c8b | |||
| e9acb6fad5 | |||
| ab402e1178 | |||
| 293701f520 | |||
| 7b38ba0e65 | |||
| 5d8ee8fc28 | |||
| 3e2e4be680 | |||
| 3863fe6412 | |||
| 2b71ea21ad | |||
| 36f21c5a4f | |||
| cf90bd9c86 | |||
| 5f159c43c5 | |||
| c02613e15f | |||
| 32cd1175fb | |||
| 0152e053e1 | |||
| 8d1e73edc7 | |||
| a5f51eadf1 | |||
| 4ac21a4f63 | |||
| 91fdf2aa25 | |||
| 44614d4a7d | |||
| 0e9f617667 | |||
| 7e7e348a14 | |||
| cc3d0d1ef7 | |||
| 5b608718bb | |||
| 3a6ab81549 | |||
| c48681b2f0 | |||
| 86d786cbc0 | |||
| ec653b7b80 | |||
| cbc34e1c8a | |||
| 1f37d94f9e | |||
| 3ee0e041fa | |||
| 4074f4fffa | |||
| 7286fd6e3f | |||
| 4b608117a2 | |||
| 47b4d245aa | |||
| 36ff508fec | |||
| 772b5fdf0f | |||
| eace21dcae | |||
| 163080b609 | |||
| 4938fbffa8 | |||
| d5db20c296 | |||
| 415cb857d9 | |||
| a641250da6 | |||
| 4d674a3f17 | |||
| 12d9a13af0 | |||
| 164841f299 | |||
| 778361686c | |||
| 29907a4c3f | |||
| 81f38342bf | |||
| 36b93c8dc7 | |||
| e95fdbbc37 | |||
| 3001f115b6 | |||
| 21649d81d2 | |||
| 5118ba3dd2 | |||
| f9409cbe43 | |||
| 572d17f46b | |||
| f466f1bf46 | |||
| 594315d90b | |||
| f84895f1f1 | |||
| 952810f76c | |||
| 73ccbedcdb | |||
| 7ef83311bb | |||
| 416c376077 | |||
| ef83a07066 | |||
| ae0c1573fd | |||
| 378e5acd23 | |||
| a56daa6c06 | |||
| 84399e62ae | |||
| 387615e99f | |||
| f98ab2d037 | |||
| 19ce08b4d0 | |||
| 8cc2dc715c | |||
| ca20a2dc06 | |||
| f9b1a96c89 | |||
| 854f07d735 | |||
| 7f4f01009b | |||
| 117b01acbd | |||
| 2b38ddf78d | |||
| 5e51107711 | |||
| 3bb33bdeed | |||
| 9b9fa009d1 | |||
| 072ad8d371 | |||
| 8846ffec64 | |||
| 3b72ed6e1a | |||
| 35b7c0f558 | |||
| d5d80f4247 | |||
| e915ed182d | |||
| c3aed2543e | |||
| e502ad13f9 | |||
| 952d924581 | |||
| 0484aba892 | |||
| 03c84d0f11 | |||
| 086f98471e | |||
| cc4f0d8acc | |||
| c7bd4b5c1d | |||
| 14e3b34a8e | |||
| 6354dddff2 | |||
| c50c3699d9 | |||
| 6a7f955818 | |||
| 9cf457be0a | |||
| e31383a8f1 | |||
| 13b8dc61ba | |||
| 61085f6141 | |||
| d8cb1daa78 | |||
| de2e341947 | |||
| e944a0239d | |||
| ce8db12b22 | |||
| 1d41129b6c | |||
| 6d6c3ad2c4 | |||
| 0b532579d8 | |||
| b9007dc721 | |||
| 211efffa10 | |||
| e3b50b7d12 | |||
| aae49f1d68 | |||
| 6b4141247e | |||
| 327f6e7e25 | |||
| 296c0a6b70 | |||
| 356b6e0483 | |||
| 08a473fb35 | |||
| 893eef846d | |||
| 4ecd35c275 | |||
| 27a7d9f9d1 | |||
| c0abab226d | |||
| f1320b79ce | |||
| bf41197b97 | |||
| 3f7fcad9ac | |||
| e2ad0ed9f7 | |||
| d2158966db | |||
| 8086c66ab8 | |||
| 7d37195c1a | |||
| 241cf10bdb | |||
| 337ae05ed8 | |||
| 378e39d7ad | |||
| 8fb3aef917 | |||
| c86cb4e9a5 | |||
| 8ca240fb2c | |||
| 9ea697ac09 | |||
| 37eaa49e4c | |||
| e64ca7c583 | |||
| 62a7a07127 | |||
| bc618ec290 | |||
| 0780859a4d | |||
| 79818f73c0 | |||
| 957d7fbe2a | |||
| 6e9d3092a7 | |||
| 7dab927260 | |||
| c417517f43 | |||
| fd0314a6bd | |||
| 6a05d60f41 | |||
| cd84c5ad08 | |||
| b0187d7f28 | |||
| 7a1d64fff9 | |||
| debcf19199 | |||
| ea76425f86 | |||
| 88936b6216 | |||
| e6edcd9a7f | |||
| af78762421 | |||
| bf159bd316 | |||
| 9eda40234f | |||
| 00336f554f | |||
| a524b9ae9b | |||
| 3f1bcac077 | |||
| 679ced7840 | |||
| 7422f54212 | |||
| b0384d0335 | |||
| 6b64039fcb | |||
| 19e7c708ce | |||
| c8ca5803fc | |||
| 5f48abb451 | |||
| 491fd6b74d | |||
| f1ff24d634 | |||
| 0242383ec3 | |||
| 768887fc0f | |||
| 958c13e02d | |||
| f417b51fb6 | |||
| 47a1f757a9 | |||
| 27ad3b917f | |||
| 93a5784c58 | |||
| e9fd73141d | |||
| 6c005b3d35 | |||
| 2967bc5988 | |||
| 55772eec5a | |||
| c2adda1cfe | |||
| 51d77aea2e | |||
| 8f456ea73b | |||
| 6459582952 | |||
| 3796882d22 | |||
| 4db69c8eac | |||
| f6a86e5527 | |||
| 063b35f1dc | |||
| b61147aed0 | |||
| fd3516bc82 | |||
| 5e4a91f996 | |||
| df4331da04 | |||
| fe3a983d35 | |||
| 53c349cb86 | |||
| 8f37f15a33 | |||
| 81385cf820 | |||
| cce65e19e1 | |||
| c4b02645f5 | |||
| 49e70746f0 | |||
| 00ace3bb63 | |||
| efde37eb36 | |||
| 84499ab969 | |||
| 5d26bb2566 | |||
| 657450c40c | |||
| cf2b659491 | |||
| e9679ce993 | |||
| 68c5d61d60 | |||
| c4f0236ec0 | |||
| 1839c144fa | |||
| d077936a21 | |||
| 6c1638890c | |||
| 7f0f789953 | |||
| 83a2a7a1c2 | |||
| 70fb4d452e | |||
| 5ed1d4e178 | |||
| 35834d3dba | |||
| 260d9b9770 | |||
| 3907e9eedd | |||
| cf8b00890f | |||
| f1fd25e95e | |||
| 426503e062 | |||
| b1834b7cf8 | |||
| 27f9cd591d | |||
| 5bbc7c8ba2 | |||
| 08f8f58971 | |||
| 7871e705bf | |||
| 1820308ba2 | |||
| a07229846f | |||
| a7e4656834 | |||
| 872d54a2dd | |||
| 496136b52c | |||
| c4eff00ed7 | |||
| 27d8aa0f04 | |||
| bb057b1dad | |||
| 3b9d84e2b1 | |||
| 1a17de9d39 | |||
| f6ade5dc84 | |||
| 2116f19106 | |||
| b73a7e07d2 | |||
| 14d3a624d8 | |||
| dd88345483 | |||
| e58d5a54b1 | |||
| 2a95a5bf8a | |||
| 0c4e67a951 | |||
| 78d41b8e41 | |||
| d5347176e1 | |||
| 6d91dad8e4 | |||
| 1dd5c97ae0 | |||
| e80e5b0801 | |||
| 052d8ba879 | |||
| d08ca9585a | |||
| 50c33dfcdf | |||
| 42c3c2b804 | |||
| f83eeac5e2 | |||
| d5517ede45 | |||
| 2339f1a01d | |||
| 6129924eb2 | |||
| fce9ded30a | |||
| 8489907cf5 | |||
| 84ccde268e | |||
| c191df5434 | |||
| f49934a75b | |||
| be3326d0d9 | |||
| 7919019b67 | |||
| 89d856a487 | |||
| 978a24ffab | |||
| bd41cf377a | |||
| 3ee3f7e30b | |||
| a032614dc7 | |||
| 0377d13d3d | |||
| 06fdfc2e14 | |||
| 510552c5e6 | |||
| 6675c273fd | |||
| 9131a69983 | |||
| a8baf0ef45 | |||
| 5e4f32d808 | |||
| 5a8d18edf3 | |||
| f34b238713 | |||
| ad5c7d97ca | |||
| c35f9c1315 | |||
| e84ed61339 | |||
| 8265829105 | |||
| a76d00a08e | |||
| 131864b940 | |||
| d33a3f619a | |||
| 1a0e57d926 | |||
| 5e5845547e | |||
| 0de944be28 | |||
| 5df438fd2a | |||
| 6329f60dff | |||
| 0bf9a87293 | |||
| 6ae4c49c1a | |||
| c683ae69af | |||
| ab9b12e883 | |||
| c41b506741 | |||
| 848180dc08 | |||
| a7d39913fd | |||
| 85ca2152e4 | |||
| b11b33b63c | |||
| 239f58b584 | |||
| 474cb48a14 | |||
| 577b0dfe1d | |||
| 2918e00d33 | |||
| ffc930b871 | |||
| 55bffeba4a | |||
| 2adb14c320 | |||
| 0d4bf1c15a | |||
| a3bf2bdd8c | |||
| bc3a14cde2 | |||
| b3d4e5cfdf | |||
| 885355ce53 | |||
| a4d5b68134 | |||
| 67f2bc1385 | |||
| d8fb2f9175 | |||
| fcc8d59588 | |||
| 1f19ca1665 | |||
| d04f7fc6e9 | |||
| f9370718bc | |||
| 8d888b426f | |||
| b6bd39660f | |||
| 959ba94eca | |||
| d5cd1058ab | |||
| 80c7b04831 | |||
| 7017756140 | |||
| d9a132b649 | |||
| 60a68aa136 | |||
| 464e4c1938 | |||
| 796f630a7c | |||
| dc8f9e043d | |||
| 6afcf43ff2 | |||
| e0ea7be499 | |||
| 4bf968a45a | |||
| a86963d62d | |||
| e40f9c9730 | |||
| 96be7c8990 | |||
| 3ced3f4c82 | |||
| 72eb240c3b | |||
| 20d247b3f7 | |||
| 318457cb2c | |||
| 336c9d6caa | |||
| a7737912b0 | |||
| f244aba03d | |||
| b0c196cf82 | |||
| cf5769753a | |||
| d1217e84c7 | |||
| 172ce6c79f | |||
| 2746efeb25 | |||
| b2e7fb01a9 | |||
| 9ef1545d06 | |||
| fc1d58b631 | |||
| 2ebad55a59 | |||
| d66a05dc41 | |||
| 998a5b080d | |||
| 39a0f54b0d | |||
| 024a823c78 | |||
| 1bbb424322 | |||
| b04f04776b | |||
| f0860ec145 | |||
| 658e0c6b03 | |||
| 49fa093767 | |||
| 51aed3ca0a | |||
| b9cc914729 | |||
| d084a37e11 | |||
| cfd2c41c21 | |||
| 9dee4c158d | |||
| 6b8011228e | |||
| 2cd27d0d4a | |||
| 3dff09424d | |||
| 8e15a6e798 | |||
| 2756e12762 | |||
| 4eb71bcd14 | |||
| 2177df51a8 | |||
| 40c8e4832a | |||
| 3377bd4ae5 | |||
| 38c4f4f76c | |||
| 280c7c851f | |||
| af9ccf0c09 | |||
| e7cdac90f5 | |||
| 7aefcab8b0 | |||
| 514b90ac69 | |||
| dbcb97949f | |||
| 76d559efc1 | |||
| 8d8584849c | |||
| 59a2cbefcb | |||
| c568284f1b | |||
| a8b26570e0 | |||
| 5a74b40ae4 | |||
| 04f595cd97 | |||
| 99a3102134 | |||
| 3a42979e53 | |||
| 912a53318e | |||
| 421401ae3f | |||
| e15475449c | |||
| 31750b5ee5 | |||
| bc92f6d4a4 | |||
| 1969e78d54 | |||
| 317f666d4c | |||
| 3fe68a051a | |||
| 5bfecc6152 | |||
| e44ed2681f | |||
| 27a545f79d | |||
| 6b10f4241d | |||
| 73cc34467a | |||
| ec1ff52dfb | |||
| 2761c40781 | |||
| e981d90209 | |||
| f965e1c3ff | |||
| 5b5a79b90b | |||
| 15729e9ea0 | |||
| d9eb320bba | |||
| cba016df74 | |||
| cf36f5a23b | |||
| 34d2527606 | |||
| 8e8e695db9 | |||
| 9928f1b3c1 | |||
| 36c91c3984 | |||
| b7b1714f32 | |||
| 2d1f1640f3 | |||
| 371a30f08b | |||
| 40dd23337c | |||
| 3114dfd39b | |||
| 04b34adec6 | |||
| 594e837440 | |||
| c77fa12bda | |||
| 5674c9f4c2 | |||
| bc01488a75 | |||
| c3c6880382 | |||
| 1f2f5858c0 | |||
| 22259a322d | |||
| 06f59f4e8a | |||
| 2b7adeb220 | |||
| 05bd452f76 | |||
| a6426d0ac5 | |||
| 5dd5c9c605 | |||
| 0e4b28ac25 | |||
| 62fecdcaa8 | |||
| 440558c44f | |||
| fa9a92f214 | |||
| c5af11f6bd | |||
| ad3254deb6 | |||
| fce04b9424 | |||
| 2d512c714b | |||
| 6298c586fd | |||
| abca8535cf | |||
| 677374de86 | |||
| 92d015333a | |||
| 6c91304400 | |||
| 04b5002d8f | |||
| 9bde7a6daa | |||
| 33b54f3d0c | |||
| c5b073702c | |||
| e38bdd0d2d | |||
| 9c54e48194 | |||
| 12e048a7fb | |||
| 11400e43dc | |||
| 293b4960f3 | |||
| 22996854f7 | |||
| 71e58c768c | |||
| bb3606b64f | |||
| 7a82777fc5 | |||
| ec046411f1 | |||
| ffaf968940 | |||
| feb70aeb6b | |||
| ded106b9e3 | |||
| cfdcabc8b4 | |||
| ab448988ff | |||
| e3089d60ea | |||
| 34f892ae82 | |||
| fbbf0ed41c | |||
| 66a8780fa2 | |||
| 2c610258d1 | |||
| f7430d74a7 | |||
| 421d6db592 | |||
| 1d385fd35a | |||
| 7cb31581d5 | |||
| 768d550ee2 | |||
| 4fd7480557 | |||
| 7c0f0a59eb | |||
| 93aeee1611 | |||
| 86d9e1e816 | |||
| 73211c900b | |||
| a19d4c19d3 | |||
| cf3b7f2c16 | |||
| 2f21dd81b0 | |||
| db3b3ed9eb | |||
| 9625d94aa0 | |||
| 5dec7d534f | |||
| 0317eec10d | |||
| a34ab1d36e | |||
| 7144a0fb9b | |||
| 421924b73f | |||
| 466236e32f | |||
| 636f2d659f | |||
| 838a9c000c | |||
| 7a7c59e91a | |||
| 1ac6ab4428 | |||
| dc3c82ad40 | |||
| 0f0a2dddfe | |||
| c3f955d3f1 | |||
| 7b1832bd24 | |||
| 148c9533ae | |||
| df96318662 | |||
| d9d0be0256 | |||
| de70d82cea | |||
| 81db44f584 | |||
| d733d246f0 | |||
| 1c5170b759 | |||
| 367526f750 | |||
| 7a0830de15 | |||
| a5fbfa3748 | |||
| 912a7a1781 | |||
| 563701fed8 | |||
| 414889e03b | |||
| 8d2de036d5 | |||
| 764761cfa5 | |||
| 90a0bb5acb | |||
| 0e4379f075 | |||
| 968c5dc4aa | |||
| fedb15d5d0 | |||
| ccc6bf05e8 | |||
| a40e56bcb7 | |||
| 52453eaeff | |||
| ff3337feed | |||
| cd30a99fae | |||
| 081460e59d | |||
| 17a6d716ad | |||
| d833de793d | |||
| a6ff62c79c | |||
| 92457f7fab | |||
| 17fa2f4053 | |||
| bce84376d3 | |||
| be87cdddeb | |||
| dc22661744 | |||
| dc69d20ec9 | |||
| 22ed7ea3f2 | |||
| 2112fa919a | |||
| f65702a8a8 | |||
| 68d19d4717 | |||
| a6e0ec38e7 | |||
| 6415ae79be | |||
| 79b76fb5f4 | |||
| 42012389c4 | |||
| 4b5c43f080 | |||
| d16e5090a6 | |||
| ddbe680a58 | |||
| 2f50b57e76 | |||
| dc291fa811 | |||
| a1d499ed64 | |||
| 629f2e0043 | |||
| 5d321c4dac | |||
| 9d751e0c72 | |||
| 6f8fb561c6 | |||
| 1019872832 | |||
| 091471293d | |||
| d7281286ba | |||
| 5cfda2803d | |||
| 9ee7a14685 | |||
| 40a6574b95 | |||
| 891e1388ba | |||
| 0fba7d41a6 | |||
| 1595fb8739 | |||
| ebc852b358 | |||
| 5f5846a08b | |||
| 4d3d9cca2a | |||
| 7b77e9f9ae | |||
| 0f74e372ba | |||
| a3b99dc309 | |||
| d73d571f19 | |||
| 8a8ac1ffe6 | |||
| d463c82c95 | |||
| 558af7a454 | |||
| d57ebb3c94 | |||
| 855976df84 | |||
| 6c2a8d6047 | |||
| 38a856f7ff | |||
| fb2a7d8cd1 | |||
| b3f79e5b02 | |||
| 1722148333 | |||
| 27e96999cf | |||
| 7efa152418 | |||
| 2a45455c80 | |||
| c06f49cb3e | |||
| b837c68df8 | |||
| f3ebb2e9ce | |||
| df9f72134b | |||
| 4ff5004d7c | |||
| bdf3d60148 | |||
| e2c6546b61 | |||
| 1f0ee9837b | |||
| 71072f084e | |||
| 98651c2a14 | |||
| 74e5e5e182 | |||
| 16f9dbfe37 | |||
| 12f74de9b3 | |||
| fec49e1e28 | |||
| 9dd9bb7092 | |||
| 9c07aab2d6 | |||
| 41a84cef23 | |||
| 8942e3e78d | |||
| 040fe58693 | |||
| 45398b7660 | |||
| f3950a5a65 | |||
| 6f6c5129d1 | |||
| 139697b9cd | |||
| ddd459426d | |||
| 3387c135ad | |||
| 73133b61fb | |||
| ba0f594548 | |||
| f4fa9bf51a | |||
| 9aea85a953 | |||
| f878e5e635 | |||
| 4e2fb38d62 | |||
| ee845376b5 | |||
| 75234da135 | |||
| 7dc9434aec | |||
| 5986cf4254 | |||
| f6db636473 | |||
| b30db08110 | |||
| 2dbef6105d | |||
| eeee9625c1 | |||
| 76559b352b | |||
| a3bf0d6002 | |||
| 96ae0dd23a | |||
| 9c9e04c5a0 | |||
| 15381c7832 | |||
| 175f929023 | |||
| a23846b3a1 | |||
| 42c74e864a | |||
| 809f5d6d8e | |||
| ff41a61432 | |||
| 28b531593a | |||
| 4d2f4f1be3 | |||
| f97415755b | |||
| 67fa82cf14 | |||
| 1d38f5a4d5 | |||
| e70f8471a8 | |||
| 093e737af9 | |||
| 1ae0b44bc5 | |||
| 17aeec59a3 | |||
| b20507ef0a | |||
| 753995a91d | |||
| 67c67dd86d | |||
| fdc0b283d7 | |||
| 2abc51789e | |||
| 1190b9c278 | |||
| 3a8e049093 | |||
| f32a647a20 | |||
| 4645f512d1 | |||
| 3d89999a06 | |||
| cb5c932447 | |||
| ddf8aef4f7 | |||
| 2714ed503b | |||
| 78d96355dd | |||
| bf429b7e87 | |||
| 2f44046622 | |||
| fb106967bc | |||
| 32720bd372 | |||
| fb1de5c1c6 | |||
| 69cb71ad7e | |||
| 0a9b98ed67 | |||
| e1c4a5989b | |||
| cac988f8e2 | |||
| bbe92a3a40 | |||
| a489550752 | |||
| f1dbff1dd4 | |||
| 55ea0f398b | |||
| 38abb044d0 | |||
| ca4e76b34f | |||
| 55e0086958 | |||
| 050ebb3b19 | |||
| 31f788eb5e | |||
| f23b16db2b | |||
| 060f80c239 | |||
| 6c3d3b98b8 | |||
| 21dfbd0103 | |||
| 1a10569f6d | |||
| 33396ca9c1 | |||
| 36ba1ff790 | |||
| fdfcff2bb5 | |||
| c74c1a0c5f | |||
| faca83e1e8 | |||
| 759ab54e59 | |||
| 3c61524f26 | |||
| 40013c2b61 | |||
| 5d5e7393f8 | |||
| 71c5511e6c | |||
| ea83982062 | |||
| cdbbdcba5f | |||
| aeb708fe07 | |||
| 78c67ed53d | |||
| ea37ee6cb3 | |||
| 2e67c5a045 | |||
| 752bc5a454 | |||
| 3a4bf8f213 | |||
| bc20664c18 | |||
| e27690e894 | |||
| bac5ac18f7 | |||
| e906b87450 | |||
| 9d0415f9e9 | |||
| 1d807911e4 | |||
| f51f8ffe45 | |||
| ea9930816f | |||
| 699cb92e86 | |||
| f4f4f2d314 | |||
| 374472deda | |||
| b27f0dd490 | |||
| 141d2b5626 | |||
| cf0f44823a | |||
| 6355113af9 | |||
| 00ef7ec522 | |||
| 9497a4cb5a | |||
| 0f71667625 | |||
| 8b20e0166d | |||
| 567644dabd | |||
| 9ef8cdadf6 | |||
| c911568306 | |||
| ce02f798e4 | |||
| 21bb2fb03f | |||
| 11311d07e5 | |||
| 4426bf2615 | |||
| 515e973964 | |||
| 0a6b934ac1 | |||
| b2e3013898 | |||
| 757cedc233 | |||
| ab316b348a | |||
| 7b7c4bd116 | |||
| 82e751a153 | |||
| c5c50a2141 | |||
| 9c32e630a0 | |||
| 02e26996c1 | |||
| b25b72ae19 | |||
| ff36375581 | |||
| c9f5edbc1d | |||
| ec00e0a952 | |||
| 51a4b86495 | |||
| c3866b7d6b | |||
| 6dafca79be | |||
| 7aca8d2d1c | |||
| 7daef74fc6 | |||
| 58d0f3053d | |||
| 649f644c75 | |||
| ad2a26611a | |||
| 89bb7d0211 | |||
| b3564bf2b4 | |||
| 16f452cf2e | |||
| 56cedad707 | |||
| 4b6325908b | |||
| 460d8fc094 | |||
| c435236ceb | |||
| 39254229a0 | |||
| 6182b205c8 | |||
| e528b439bc | |||
| 629140d66c | |||
| 46ed4f2de1 | |||
| 46d55a8ada | |||
| 5e6af3d732 | |||
| 0d07c58989 | |||
| 6f80be0653 | |||
| 1916e688a6 | |||
| 07e56ddeb5 | |||
| 88c8009116 | |||
| 42d843297d | |||
| 15cdeeddaf | |||
| 3c13a265bc | |||
| 93eec9ac3c | |||
| df7dbff683 | |||
| 2e6265963b | |||
| b88b18df93 | |||
| fbf5333b39 | |||
| c6e3b490f5 | |||
| 19677f0622 | |||
| a8932c2c25 | |||
| f93e33d9de | |||
| 649e6efc4a | |||
| a7d3619ec4 | |||
| daca3a5fc9 | |||
| 135a52020c | |||
| bf21ed7282 | |||
| b5f65e3304 | |||
| 45400a1758 | |||
| acc88bc2b4 | |||
| 98b0595275 | |||
| 4efecfdfa0 | |||
| b5afb9d3ab | |||
| 6a1d58d4e7 | |||
| d2a3db4c78 | |||
| f207788c0a | |||
| 84b44069c8 | |||
| 09ed3f37db | |||
| f444604e7c | |||
| e1c9885566 | |||
| 4e7d905783 | |||
| cb35e3a766 | |||
| 67fe5ed699 | |||
| e863fd78d6 | |||
| 4ea2518e79 | |||
| b508ab240f | |||
| c545ec727c | |||
| f290b9a145 | |||
| fa1eb9bf25 | |||
| 3a32b83181 | |||
| 2e393b7d5c | |||
| 12e5b8124e | |||
| 26e939c1eb | |||
| f09390a412 | |||
| 3067807802 | |||
| 60f4c9f5b3 | |||
| b0ecafcb8d | |||
| ddfb76e9e0 | |||
| 6f27f742fe | |||
| c1a64301ce | |||
| 1ee690e87c | |||
| 28e0dbc02f | |||
| a2604a36bc | |||
| d031c5c7fa | |||
| 5d01b32c10 | |||
| 4fe651079c | |||
| a573ea4aeb | |||
| 13704d9da5 | |||
| 73a1e137e6 | |||
| 0ec9c6c3cf | |||
| 4aa275e13c | |||
| b557a73c3f | |||
| b66098ea20 | |||
| d0cefecd0d | |||
| 38a4e9806f | |||
| 3c64a57c84 | |||
| 36b0796976 | |||
| 3241d81ce5 | |||
| d7a188fb34 | |||
| 5b217b2042 | |||
| 4cb2a92037 | |||
| 24d90c17c2 | |||
| c95c6d72e9 | |||
| 99b174f495 | |||
| 5949ef0e2c | |||
| d75d64df64 | |||
| a5164df293 | |||
| 690113dd73 | |||
| fe87160b19 | |||
| fffe1be521 | |||
| dc02bcee74 | |||
| e7a9313135 | |||
| 5492845659 | |||
| 29dfe89137 | |||
| 0da3f84a2e | |||
| c25b0c1a66 | |||
| 7c7314f673 | |||
| 869cc3d497 | |||
| f315bf074b | |||
| d33f9ddf44 | |||
| fcf0c28132 | |||
| b3e50cbb33 | |||
| 20cb709ae3 | |||
| 916a41ed60 | |||
| 9797a9993a | |||
| 04ce98148d | |||
| 34eb75f634 | |||
| 05b76281f7 | |||
| 4a35bcec21 | |||
| 518af0ef24 | |||
| a155ec0599 | |||
| 80979cf4d0 | |||
| a27ee2366e | |||
| 7bc56d7cfe | |||
| 088bdb3313 | |||
| 89d49cd925 | |||
| 84f8d8733e | |||
| 07f323222b | |||
| a321bf1a90 | |||
| 92a0763a74 | |||
| e878780808 | |||
| cb5f1fa99d | |||
| b55ac994ea | |||
| 3a8d6b80e0 | |||
| 3354a68373 | |||
| edc894f6c7 | |||
| f68714ec8e | |||
| 7be9352a3a | |||
| 3a782b6ace | |||
| 47d0b6fc14 | |||
| 8204351d67 | |||
| 4c3635a7c0 | |||
| 7ea43b0145 | |||
| 6afe6f4ecb | |||
| 273f2b61d0 | |||
| 0824873ffb | |||
| 8f99b13305 | |||
| 9253702966 | |||
| 3958450223 | |||
| cc596ef011 | |||
| 8220b11770 | |||
| 62c54cd47c | |||
| e34d0d69aa | |||
| 597e7e6f13 | |||
| b460fd61bd | |||
| c9b5df8184 | |||
| 341ecf3bbe | |||
| b6b5144ddf | |||
| deac5ff585 | |||
| 38a03ff2c8 | |||
| 527bed2b53 | |||
| 318166f8b0 | |||
| 394c751d7d | |||
| 86d707ad51 | |||
| c3792db0e5 | |||
| 16e42e6d6d | |||
| 53c1674382 | |||
| 85917d4769 | |||
| ae0d35c727 | |||
| 086dd284d6 | |||
| 8ba35a2dc3 | |||
| 48dfb1c8ca | |||
| 5a83a44112 | |||
| 58520859e5 | |||
| 4faba0fe8b | |||
| c4b0155cc2 | |||
| 38b18202fc | |||
| 0f17a7d828 | |||
| 9da5b9f4bb | |||
| a7fdc7b992 | |||
| f519e22e6d | |||
| ecac4dd72a | |||
| b6c45485bc | |||
| ec46932259 | |||
| 10182f1182 | |||
| cfaec9d608 | |||
| 0f6157a49d | |||
| 1df6373cb1 | |||
| ea32cd85fe | |||
| 716524c151 | |||
| 96722bba08 | |||
| 4e20a20927 | |||
| a0d1004909 | |||
| ccab950d16 | |||
| 2018c90ae2 | |||
| 793360c5bb | |||
| d8b1a38350 | |||
| 499a3e3227 | |||
| 73a9fdca2a | |||
| 06dd9b8ed8 | |||
| a86cb932cf | |||
| 2fae0a9f47 | |||
| 2ec9192010 | |||
| 202eff984d | |||
| b172b538fc | |||
| a34271adf9 | |||
| 2cf134668c | |||
| b94b220156 | |||
| 26921cbe68 | |||
| 8844674825 | |||
| c9fbe2cb92 | |||
| 2b941ccc93 | |||
| ed080ae988 | |||
| f31e89d5af | |||
| 52c311e47f | |||
| 5b54d4de7a | |||
| 96152f6577 | |||
| e881b3c5de | |||
| e86b507da7 | |||
| 2fc3a822c8 | |||
| 1b0e1edb08 | |||
| d107b79c63 | |||
| c5ab442f46 | |||
| c5677df56e | |||
| 21ba0fb8a4 | |||
| 69319a0569 | |||
| 37d8e55991 | |||
| 8d20edb028 | |||
| 7564c4e7f4 | |||
| 26e02a9b8b | |||
| 25ec133574 | |||
| d88ede92b9 |
@@ -7,20 +7,56 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: [node, bun]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Node version
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
# bun.sh downloads currently fail with:
|
||||
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||
|
||||
- name: Setup Node.js (tooling for bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
@@ -41,11 +77,314 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Lint
|
||||
- name: Lint (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test
|
||||
- name: Test (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
- name: Build (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm build
|
||||
|
||||
- name: Protocol check (node)
|
||||
if: matrix.runtime == 'node'
|
||||
run: pnpm protocol:check
|
||||
|
||||
- name: Lint (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx biome check src
|
||||
|
||||
- name: Test (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx vitest run
|
||||
|
||||
- name: Build (bun)
|
||||
if: matrix.runtime == 'bun'
|
||||
run: bunx tsc -p tsconfig.json
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: SwiftLint
|
||||
run: swiftlint --config .swiftlint.yml
|
||||
|
||||
- name: SwiftFormat (lint mode)
|
||||
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift tests (coverage)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: brew install swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: iOS tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
DEST_ID="$(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
def sh(args: list[str]) -> str:
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
# Prefer an already-created iPhone simulator if it exists.
|
||||
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for runtime, devs in (devices.get("devices") or {}).items():
|
||||
for dev in devs or []:
|
||||
if not dev.get("isAvailable"):
|
||||
continue
|
||||
name = str(dev.get("name") or "")
|
||||
udid = str(dev.get("udid") or "")
|
||||
if not udid or not name.startswith("iPhone"):
|
||||
continue
|
||||
candidates.append((name, udid))
|
||||
|
||||
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
|
||||
if candidates:
|
||||
print(candidates[0][1])
|
||||
sys.exit(0)
|
||||
|
||||
# Otherwise, create one from the newest available iOS runtime.
|
||||
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
|
||||
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
|
||||
if not ios:
|
||||
print("No available iOS runtimes found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def version_key(rt: dict) -> tuple[int, ...]:
|
||||
parts: list[int] = []
|
||||
for p in str(rt.get("version") or "0").split("."):
|
||||
try:
|
||||
parts.append(int(p))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
ios.sort(key=version_key, reverse=True)
|
||||
runtime = ios[0]
|
||||
runtime_id = str(runtime.get("identifier") or "")
|
||||
if not runtime_id:
|
||||
print("Missing iOS runtime identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
supported = runtime.get("supportedDeviceTypes") or []
|
||||
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
|
||||
if not iphones:
|
||||
print("No iPhone device types for iOS runtime.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
iphones.sort(
|
||||
key=lambda dt: (
|
||||
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
|
||||
str(dt.get("name") or ""),
|
||||
)
|
||||
)
|
||||
device_type_id = str(iphones[0].get("identifier") or "")
|
||||
if not device_type_id:
|
||||
print("Missing iPhone device type identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
||||
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
||||
if not udid:
|
||||
print("Failed to create iPhone simulator.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(udid)
|
||||
PY
|
||||
)"
|
||||
echo "Using iOS Simulator id: $DEST_ID"
|
||||
xcodebuild test \
|
||||
-project apps/ios/Clawdis.xcodeproj \
|
||||
-scheme Clawdis \
|
||||
-destination "platform=iOS Simulator,id=$DEST_ID" \
|
||||
-resultBundlePath "$RESULT_BUNDLE_PATH" \
|
||||
-enableCodeCoverage YES
|
||||
|
||||
- name: iOS coverage summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
|
||||
|
||||
- name: iOS coverage gate (43%)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
target_name = "Clawdis.app"
|
||||
minimum = 0.43
|
||||
|
||||
report = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
|
||||
target_coverage = None
|
||||
for target in report.get("targets", []):
|
||||
if target.get("name") == target_name:
|
||||
target_coverage = float(target["lineCoverage"])
|
||||
break
|
||||
|
||||
if target_coverage is None:
|
||||
print(f"Could not find coverage for target: {target_name}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
|
||||
if target_coverage + 1e-12 < minimum:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Android unit tests + debug build
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdisKit/.build/
|
||||
bin/
|
||||
bin/clawdis-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/ClawdisKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
apps/ios/fastlane/report.xml
|
||||
apps/ios/fastlane/Preview.html
|
||||
apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
@@ -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,apps/macos/Sources/ClawdisProtocol
|
||||
@@ -0,0 +1,142 @@
|
||||
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
||||
|
||||
included:
|
||||
- apps/macos/Sources
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.build"
|
||||
- "**/.swiftpm"
|
||||
- "**/DerivedData"
|
||||
- "**/Generated"
|
||||
- "**/Resources"
|
||||
- "**/Package.swift"
|
||||
- "**/Tests/Resources"
|
||||
- node_modules
|
||||
- dist
|
||||
- coverage
|
||||
- "*.playground"
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
- unused_import
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- contains_over_first_not_nil
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- fallthrough
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- pattern_matching_keywords
|
||||
- private_outlet
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
|
||||
disabled_rules:
|
||||
# SwiftFormat handles these
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- trailing_comma
|
||||
- vertical_whitespace
|
||||
- indentation_width
|
||||
|
||||
# Style exclusions
|
||||
- explicit_self
|
||||
- identifier_name
|
||||
- file_header
|
||||
- explicit_top_level_acl
|
||||
- explicit_acl
|
||||
- explicit_type_interface
|
||||
- missing_docs
|
||||
- required_deinit
|
||||
- prefer_nimble
|
||||
- quick_discouraged_call
|
||||
- quick_discouraged_focused_test
|
||||
- quick_discouraged_pending_test
|
||||
- anonymous_argument_in_multiline_closure
|
||||
- no_extension_access_modifier
|
||||
- no_grouping_extension
|
||||
- switch_case_on_newline
|
||||
- strict_fileprivate
|
||||
- extension_access_modifier
|
||||
- convenience_type
|
||||
- no_magic_numbers
|
||||
- one_declaration_per_file
|
||||
- vertical_whitespace_between_cases
|
||||
- vertical_whitespace_closing_braces
|
||||
- superfluous_else
|
||||
- number_separator
|
||||
- prefixed_toplevel_constant
|
||||
- opening_brace
|
||||
- trailing_closure
|
||||
- contrasted_opening_brace
|
||||
- sorted_imports
|
||||
- redundant_type_annotation
|
||||
- shorthand_optional_binding
|
||||
- untyped_error_in_catch
|
||||
- file_name
|
||||
- todo
|
||||
|
||||
force_cast: warning
|
||||
force_try: warning
|
||||
|
||||
type_name:
|
||||
min_length:
|
||||
warning: 2
|
||||
error: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
error: 80
|
||||
|
||||
function_body_length:
|
||||
warning: 150
|
||||
error: 300
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
type_body_length:
|
||||
warning: 800
|
||||
error: 1200
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 20
|
||||
error: 120
|
||||
|
||||
large_tuple:
|
||||
warning: 4
|
||||
error: 5
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 4
|
||||
error: 6
|
||||
function_level:
|
||||
warning: 5
|
||||
error: 7
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 250
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
reporter: "xcode"
|
||||
@@ -1,13 +1,13 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Claude config). Built output lives in `dist/`.
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -16,6 +16,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`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
@@ -24,15 +25,48 @@
|
||||
- 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 sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Agent-Specific Notes
|
||||
- If the relay is running in tmux (`warelay-relay`), restart it after code changes: kill pane/session and run `pnpm warelay relay --verbose` inside tmux. Check tmux before editing; keep the watcher healthy if you start it.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
|
||||
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdis send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a clawdis bug.
|
||||
|
||||
@@ -1,103 +1,338 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0-beta4 — 2025-12-27
|
||||
|
||||
### Fixes
|
||||
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
|
||||
|
||||
## 2.0.0-beta3 — 2025-12-27
|
||||
|
||||
### Highlights
|
||||
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
||||
- Per-session model selection + custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||
- Discord bot transport for DMs and guild text channels, with allowlists + mention gating.
|
||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
||||
- Command queue modes + per-session overrides (`/queue ...`) and new `agent.maxConcurrent` cap for safe parallelism across sessions.
|
||||
- Background bash tasks: `bash` auto-yields after 20s (or on demand) with a `process` tool to list/poll/log/write/kill sessions.
|
||||
- Gateway in-process restart: `clawdis_gateway` tool action triggers a SIGUSR1 restart without needing a supervisor.
|
||||
|
||||
### Breaking
|
||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||
- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set.
|
||||
- Heartbeats now run via the gateway runner (main session) and deliver to the last used channel by default. WhatsApp reply-heartbeat behavior is removed; use `agent.heartbeat.target`/`to` (or `target: "none"`) to control delivery.
|
||||
- Browser `act` no longer accepts CSS `selector`; use `snapshot` refs (default `ai`) or `evaluate` as an escape hatch.
|
||||
|
||||
### Fixes
|
||||
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
|
||||
- Heartbeat delivery now uses the last non-empty payload, preventing tool preambles from swallowing the final reply.
|
||||
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
|
||||
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
|
||||
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
|
||||
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
|
||||
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
|
||||
- Suppressed libsignal session cleanup spam from console logs unless verbose mode is enabled.
|
||||
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.
|
||||
- Group chats now honor `routing.groupChat.requireMention=false` as the default activation when no per-group override exists.
|
||||
- Gateway auth no longer supports PAM/system mode; use token or shared password.
|
||||
- Tailscale Funnel now requires password auth (no token-only public exposure).
|
||||
- Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions.
|
||||
- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings.
|
||||
- Typing indicators now start only once a reply payload is produced (no "thinking" typing for silent runs).
|
||||
- WhatsApp group typing now starts immediately only when the bot is mentioned; otherwise it waits until real output exists.
|
||||
- Streamed `<think>` segments are stripped before partial replies are emitted.
|
||||
- System prompt now tags allowlisted owner numbers as the user identity to avoid mistaken “friend” assumptions.
|
||||
- LM Studio/Ollama replies now require <final> tags; streaming ignores content until <final> begins.
|
||||
- LM Studio responses API: tools payloads no longer include `strict: null`, and LM Studio no longer gets forced `<think>/<final>` tags.
|
||||
- Identity emoji no longer auto-prefixes replies (set `messages.responsePrefix` explicitly if desired).
|
||||
- Model switches now enqueue a system event so the next run knows the active model.
|
||||
- `/model status` now lists available models (same as `/model`).
|
||||
- `process log` pagination is now line-based (omit `offset` to grab the last N lines).
|
||||
- macOS WebChat: assistant bubbles now update correctly when toggling light/dark mode.
|
||||
- macOS: avoid spawning a duplicate gateway process when an external listener already exists.
|
||||
- Node bridge: when binding to a non-loopback host (e.g. Tailnet IP), also listens on `127.0.0.1` for local connections (without creating duplicate loopback listeners for `0.0.0.0`/`127.0.0.1` binds).
|
||||
- UI perf: pause repeat animations when scenes are inactive (typing dots, onboarding glow, iOS status pulse), throttle voice overlay level updates, and reduce overlay focus churn.
|
||||
- Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
|
||||
- Gateway launchd loop fixed by removing redundant `kickstart -k`.
|
||||
- CLI now hints when Peekaboo is unauthorized.
|
||||
- WhatsApp web inbox listeners now clean up on close to avoid duplicate handlers.
|
||||
- Gateway startup now brings up browser control before external providers; WhatsApp/Telegram/Discord auto-start can be disabled with `web.enabled`, `telegram.enabled`, or `discord.enabled`.
|
||||
|
||||
### Providers & Routing
|
||||
- New Discord provider for DMs + guild text channels with allowlists and mention-gated replies by default.
|
||||
- `routing.queue` now controls queue vs interrupt behavior globally + per surface (defaults: WhatsApp/Telegram interrupt, Discord/WebChat queue).
|
||||
- `/queue <mode>` supports one-shot or per-session overrides; `/queue reset|default` clears overrides.
|
||||
- `agent.maxConcurrent` caps global parallel runs while keeping per-session serialization.
|
||||
|
||||
### macOS app
|
||||
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
|
||||
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
|
||||
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
|
||||
- Node rows now show compact app versions (build numbers moved to submenus) and offer SSH launch from Bonjour when available.
|
||||
- Menu actions are grouped below toggles; Open Canvas hides when disabled and Voice Wake now anchors the mic picker.
|
||||
- Connections now include Discord provider status + configuration UI.
|
||||
- Menu bar gains an Allow Camera toggle alongside Canvas.
|
||||
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
|
||||
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
|
||||
- OAuth storage moved; legacy session syncing metadata removed.
|
||||
- Remote SSH tunnels now get health checks; Debug → Ports highlights unhealthy tunnels and offers Reset SSH tunnel.
|
||||
- Menu bar session/node sections no longer reflow while open, keeping hover highlights aligned.
|
||||
- Menu hover highlights now span the full width (including submenu arrows).
|
||||
- Menu session rows now refresh while open without width changes (no more stuck “Loading sessions…”).
|
||||
- Menu width no longer grows on hover when moving the mouse across rows.
|
||||
- Context usage bars now have higher contrast in light mode.
|
||||
- macOS node timeouts now share a single async timeout helper for consistent behavior.
|
||||
- WebChat window defaults tightened (narrower width, edge-to-edge layout) and the SwiftUI tag removed from the title.
|
||||
|
||||
### Nodes & Canvas
|
||||
- Debug status overlay gated and toggleable on macOS/iOS/Android nodes.
|
||||
- Gateway now derives the canvas host URL via a shared helper for bridge + WS handshakes (avoids loopback pitfalls).
|
||||
- `canvas a2ui push` validates JSONL with line errors, rejects v0.9 payloads, and supports `--text` quick renders.
|
||||
- `nodes rename` lets you override paired node display names without editing JSON.
|
||||
- Android scaffold asset cleanup; iOS canvas/voice wake adjustments.
|
||||
|
||||
### Logging & Observability
|
||||
- New subsystem console formatter with color modes, shortened prefixes, and TTY detection; browser/gateway logs route through the subsystem logger.
|
||||
- WhatsApp console output streamlined; chalk/tslog typing fixes.
|
||||
|
||||
### Web UI
|
||||
- Chat is now the dashboard landing view; health status simplified; initial scroll animation removed.
|
||||
|
||||
### Build, Dev, Docs
|
||||
- Notarization flow added for macOS release artifacts; packaging scripts updated.
|
||||
- macOS signing auto-selects Developer ID → Apple Distribution → Apple Development; no ad-hoc fallback.
|
||||
- Added type-aware oxlint; docs list resolves from cwd; formatting/lint cleanup and dependency bumps (Peekaboo).
|
||||
- Docs refreshed for tools, custom model providers, Discord, queue/routing, group activation commands, logging, restart semantics, release notes, GitHub pages CTAs, and npm pitfalls.
|
||||
- `pnpm build` now skips A2UI bundling for faster builds (run `pnpm canvas:a2ui:bundle` when needed).
|
||||
|
||||
### Tests
|
||||
- Coverage added for models config merging, WhatsApp reply context, QR login flows, auto-reply behavior, and gateway SIGTERM timeouts.
|
||||
- Added gateway webhook coverage (auth, validation, and summary posting).
|
||||
- Vitest now isolates HOME/XDG config roots so tests never touch a real `~/.clawdis` install.
|
||||
|
||||
## 2.0.0-beta2 — 2025-12-21
|
||||
|
||||
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
|
||||
|
||||
### Highlights
|
||||
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
|
||||
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
|
||||
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
|
||||
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
|
||||
|
||||
### Gateway & providers
|
||||
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
|
||||
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
|
||||
- Telegram: normalized chat IDs with clearer error reporting.
|
||||
|
||||
### Canvas & browser control
|
||||
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
|
||||
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
|
||||
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
|
||||
|
||||
### macOS UI polish
|
||||
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
|
||||
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
|
||||
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
|
||||
|
||||
### Build, CI, deps
|
||||
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
|
||||
|
||||
### Docs
|
||||
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
|
||||
|
||||
## 2.0.0-beta1 — 2025-12-19
|
||||
|
||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||
|
||||
### Bug Fixes
|
||||
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
||||
|
||||
### Breaking
|
||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||
- Pi only: only the embedded Pi runtime remains, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||
|
||||
### Gateway, nodes, and automation
|
||||
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
||||
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
||||
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
||||
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
||||
|
||||
### macOS companion app
|
||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
||||
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
||||
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
||||
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
||||
|
||||
### iOS node
|
||||
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
||||
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
||||
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
||||
|
||||
### WhatsApp & agent experience
|
||||
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
||||
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
|
||||
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
|
||||
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
|
||||
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
|
||||
|
||||
### CLI, RPC, and health
|
||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||
|
||||
### Security & hardening
|
||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
||||
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
||||
|
||||
### Docs
|
||||
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
||||
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
||||
|
||||
## 1.5.0 — 2025-12-05
|
||||
|
||||
### Breaking
|
||||
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); only the embedded Pi runtime remains and related CLI helpers have been removed.
|
||||
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||
|
||||
### Changes
|
||||
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
|
||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
|
||||
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
|
||||
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
|
||||
|
||||
## 1.4.1 — 2025-12-04
|
||||
|
||||
### Changes
|
||||
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
|
||||
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
|
||||
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
|
||||
|
||||
## 1.4.0 — 2025-12-03
|
||||
|
||||
### Highlights
|
||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `agent.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||
|
||||
### Reliability & UX
|
||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
||||
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
||||
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
||||
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
||||
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
||||
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
||||
|
||||
### Security / Hardening
|
||||
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
|
||||
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
||||
|
||||
### Bug Fixes
|
||||
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
||||
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
||||
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||
|
||||
### Testing
|
||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||
|
||||
## 1.3.0 — 2025-12-02
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** agent selection via config/CLI plus per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||
|
||||
### Bug Fixes
|
||||
- Empty `result` fields no longer leak raw JSON to users.
|
||||
- Heartbeat alerts now honor `responsePrefix`.
|
||||
- Command failures return user-friendly messages.
|
||||
- Test session isolation to avoid touching real `sessions.json`.
|
||||
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
|
||||
- Web send respects media kind (image/audio/video/document) with correct limits.
|
||||
|
||||
### Changes
|
||||
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
|
||||
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||
- Early `allowFrom` filtering before decryption.
|
||||
- Same-phone mode with echo detection and optional message prefix marker.
|
||||
|
||||
## 1.2.2 — 2025-11-28
|
||||
|
||||
### Changes
|
||||
- **Manual heartbeat sends:** `warelay heartbeat` accepts `--message/--body` with `--provider web|twilio` to push real outbound messages through the same plumbing; `--dry-run` previews payloads without sending.
|
||||
- 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 `agent.mediaMaxMb`.
|
||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||
- Typing indicator refresh during commands; configurable via `agent.typingIntervalSeconds`.
|
||||
- Optional audio transcription via external CLI.
|
||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||
- Web provider refactor; logout command; web-only gateway start helper.
|
||||
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
||||
- Relay help prints effective heartbeat/backoff when in web mode.
|
||||
|
||||
## 1.0.4 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- 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,202 +1,228 @@
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp.
|
||||
# 🦞 CLAWDIS — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="README-header.png" alt="warelay header" width="640">
|
||||
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/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://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="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 *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
### 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).
|
||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
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.
|
||||
```
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdis …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdis.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Quick Start (pick your engine)
|
||||
Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **one** path:
|
||||
## What Clawdis does
|
||||
|
||||
**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.
|
||||
- **Personal assistant** — one user, one identity, one memory surface.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS.
|
||||
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
||||
|
||||
**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`
|
||||
## How it works (short)
|
||||
|
||||
> Already developing locally? You can still run `pnpm install` and `pnpm warelay ...` from the repo, but end users only need the npm package.
|
||||
- **Gateway** is the single source of truth for sessions/providers.
|
||||
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
|
||||
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
|
||||
- **Agent runtime** is **Pi** in RPC mode.
|
||||
|
||||
## 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.
|
||||
## Quick start (from source)
|
||||
|
||||
## 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` |
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
|
||||
### 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.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
### 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.
|
||||
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
||||
pnpm clawdis login
|
||||
|
||||
## 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.
|
||||
# Start the gateway
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
|
||||
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.
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
|
||||
# Send a message
|
||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Architecture
|
||||
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
|
||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||
|
||||
## Companion apps
|
||||
|
||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||
|
||||
### macOS (Clawdis.app)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
|
||||
### iOS node (internal)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdis nodes …`.
|
||||
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment (.env)
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `TWILIO_ACCOUNT_SID` | Yes (Twilio provider) | Twilio Account SID |
|
||||
| `TWILIO_AUTH_TOKEN` | Yes* | Auth token (or use API key/secret) |
|
||||
| `TWILIO_API_KEY` | Yes* | API key if not using auth token |
|
||||
| `TWILIO_API_SECRET` | Yes* | API secret paired with `TWILIO_API_KEY` |
|
||||
| `TWILIO_WHATSAPP_FROM` | Yes (Twilio provider) | WhatsApp-enabled sender, e.g. `whatsapp:+19995550123` |
|
||||
| `TWILIO_SENDER_SID` | Optional | Overrides auto-discovery of the sender SID |
|
||||
|
||||
(*Provide either auth token OR api key/secret.)
|
||||
|
||||
### Auto-reply config (`~/.warelay/warelay.json`, JSON5)
|
||||
- Controls who is allowed to trigger replies (`allowFrom`), reply mode (`text` or `command`), templates, and session behavior.
|
||||
- Example (Claude command):
|
||||
Minimal `~/.clawdis/clawdis.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
allowFrom: ["+12345550000"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
|
||||
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
|
||||
claudeOutputFormat: "text",
|
||||
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
|
||||
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
|
||||
}
|
||||
routing: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 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.
|
||||
### WhatsApp
|
||||
|
||||
### 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`:
|
||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.requireMention`, `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
logging: {
|
||||
level: "warn",
|
||||
file: "/tmp/warelay/custom.log"
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
### Discord
|
||||
|
||||
### 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.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. |
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}` and `{{IsNewSession}}` when sessions are enabled.
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
Browser control (optional):
|
||||
|
||||
## Troubleshooting Tips
|
||||
- Send/receive issues: run `pnpm warelay status --limit 20 --lookback 240 --json` to inspect recent traffic.
|
||||
- Auto-reply not firing: ensure sender is in `allowFrom` (or unset), and confirm `.env` + `warelay.json` are loaded (reload shell after edits).
|
||||
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
|
||||
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
## Docs
|
||||
|
||||
## 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.
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail setup --account you@gmail.com
|
||||
clawdis hooks gmail run
|
||||
```
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: swabble
|
||||
steps:
|
||||
- name: Checkout swabble
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: swabble
|
||||
|
||||
- name: Select Xcode 26.1 (prefer 26.1.1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# pick the newest installed 26.1.x, fallback to newest 26.x
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
fi
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
echo "No Xcode 26.x found on runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Selecting $CANDIDATE"
|
||||
sudo xcode-select -s "$CANDIDATE"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Show Swift version
|
||||
run: swift --version
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
brew update
|
||||
brew install swiftlint swiftformat
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
./scripts/format.sh
|
||||
git diff --exit-code
|
||||
|
||||
- name: Lint
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
- name: Test
|
||||
run: swift test --parallel
|
||||
@@ -0,0 +1,33 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# SwiftPM / Build
|
||||
/.build
|
||||
/.swiftpm
|
||||
/DerivedData
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Editors
|
||||
/.vscode
|
||||
.idea/
|
||||
|
||||
# Xcode artifacts
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
*.xcplayground
|
||||
playground.xcworkspace
|
||||
timeline.xctimeline
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
@@ -0,0 +1,8 @@
|
||||
--swiftversion 6.2
|
||||
--indent 4
|
||||
--maxwidth 120
|
||||
--wraparguments before-first
|
||||
--wrapcollections before-first
|
||||
--stripunusedargs closure-only
|
||||
--self remove
|
||||
--header ""
|
||||
@@ -0,0 +1,43 @@
|
||||
# SwiftLint for swabble
|
||||
included:
|
||||
- Sources
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.swiftpm"
|
||||
- "**/.build"
|
||||
- "**/DerivedData"
|
||||
- "**/.DS_Store"
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- explicit_init
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- vertical_parameter_alignment_on_call
|
||||
- vertical_whitespace_opening_braces
|
||||
- vertical_whitespace_closing_braces
|
||||
|
||||
disabled_rules:
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- indentation_width
|
||||
- identifier_name
|
||||
- explicit_self
|
||||
- file_header
|
||||
- todo
|
||||
|
||||
line_length:
|
||||
warning: 140
|
||||
error: 180
|
||||
|
||||
reporter: "xcode"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — 2025-12-23
|
||||
|
||||
### Highlights
|
||||
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
|
||||
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
|
||||
|
||||
### Changes
|
||||
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
|
||||
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"originHash" : "2012d083159d375d07febbc184c592c569d7ab48247045e35a762e3269d4cadc",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||
"version" : "600.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-testing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-testing",
|
||||
"state" : {
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.target(
|
||||
name: "SwabbleKit",
|
||||
path: "Sources/SwabbleKit",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
"SwabbleKit",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "SwabbleKitTests",
|
||||
dependencies: [
|
||||
"SwabbleKit",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
]),
|
||||
],
|
||||
swiftLanguageModes: [.v6])
|
||||
@@ -0,0 +1,111 @@
|
||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||
|
||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
||||
|
||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
||||
- **Services**: launchd helper stubs for start/stop/install.
|
||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
||||
|
||||
## Quick start
|
||||
```bash
|
||||
# Install deps
|
||||
brew install swiftformat swiftlint
|
||||
|
||||
# Build
|
||||
swift build
|
||||
|
||||
# Write default config (~/.config/swabble/config.json)
|
||||
swift run swabble setup
|
||||
|
||||
# Run foreground daemon
|
||||
swift run swabble serve
|
||||
|
||||
# Test your hook
|
||||
swift run swabble test-hook "hello world"
|
||||
|
||||
# Transcribe a file to SRT
|
||||
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
||||
```
|
||||
|
||||
## Use as a library
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [
|
||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
||||
]),
|
||||
]
|
||||
```
|
||||
|
||||
## CLI
|
||||
- `serve` — foreground loop (mic → wake → hook)
|
||||
- `transcribe <file>` — offline transcription (txt|srt)
|
||||
- `test-hook "text"` — invoke configured hook
|
||||
- `mic list|set <index>` — enumerate/select input device
|
||||
- `setup` — write default config JSON
|
||||
- `doctor` — check Speech auth & device availability
|
||||
- `health` — prints `ok`
|
||||
- `tail-log` — last 10 transcripts
|
||||
- `status` — show wake state + recent transcripts
|
||||
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
|
||||
- `start|stop|restart` — placeholders until full launchd wiring
|
||||
|
||||
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
|
||||
|
||||
## Config
|
||||
`~/.config/swabble/config.json` (auto-created by `setup`):
|
||||
```json
|
||||
{
|
||||
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
|
||||
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
|
||||
"hook": {
|
||||
"command": "",
|
||||
"args": [],
|
||||
"prefix": "Voice swabble from ${hostname}: ",
|
||||
"cooldownSeconds": 1,
|
||||
"minCharacters": 24,
|
||||
"timeoutSeconds": 5,
|
||||
"env": {}
|
||||
},
|
||||
"logging": {"level": "info", "format": "text"},
|
||||
"transcripts": {"enabled": true, "maxEntries": 50},
|
||||
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
|
||||
}
|
||||
```
|
||||
|
||||
- Config path override: `--config /path/to/config.json` on relevant commands.
|
||||
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
|
||||
|
||||
## Hook protocol
|
||||
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
|
||||
```
|
||||
<command> <args...> "<prefix><text>"
|
||||
```
|
||||
Environment variables:
|
||||
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
|
||||
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
|
||||
- plus any `hook.env` key/values
|
||||
|
||||
## Speech pipeline
|
||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
- launchd control (load/bootout, PID + status socket)
|
||||
- JSON logging + PII redaction toggle
|
||||
- Stronger wake-word detection and control socket status/health
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
public struct SwabbleConfig: Codable, Sendable {
|
||||
public struct Audio: Codable, Sendable {
|
||||
public var deviceName: String = ""
|
||||
public var deviceIndex: Int = -1
|
||||
public var sampleRate: Double = 16000
|
||||
public var channels: Int = 1
|
||||
}
|
||||
|
||||
public struct Wake: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var word: String = "clawd"
|
||||
public var aliases: [String] = ["claude"]
|
||||
}
|
||||
|
||||
public struct Hook: Codable, Sendable {
|
||||
public var command: String = ""
|
||||
public var args: [String] = []
|
||||
public var prefix: String = "Voice swabble from ${hostname}: "
|
||||
public var cooldownSeconds: Double = 1
|
||||
public var minCharacters: Int = 24
|
||||
public var timeoutSeconds: Double = 5
|
||||
public var env: [String: String] = [:]
|
||||
}
|
||||
|
||||
public struct Logging: Codable, Sendable {
|
||||
public var level: String = "info"
|
||||
public var format: String = "text" // text|json placeholder
|
||||
}
|
||||
|
||||
public struct Transcripts: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var maxEntries: Int = 50
|
||||
}
|
||||
|
||||
public struct Speech: Codable, Sendable {
|
||||
public var localeIdentifier: String = Locale.current.identifier
|
||||
public var etiquetteReplacements: Bool = false
|
||||
}
|
||||
|
||||
public var audio = Audio()
|
||||
public var wake = Wake()
|
||||
public var hook = Hook()
|
||||
public var logging = Logging()
|
||||
public var transcripts = Transcripts()
|
||||
public var speech = Speech()
|
||||
|
||||
public static let defaultPath = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/swabble/config.json")
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum ConfigError: Error {
|
||||
case missingConfig
|
||||
}
|
||||
|
||||
public enum ConfigLoader {
|
||||
public static func load(at path: URL?) throws -> SwabbleConfig {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
throw ConfigError.missingConfig
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
|
||||
}
|
||||
|
||||
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(config)
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public struct HookJob: Sendable {
|
||||
public let text: String
|
||||
public let timestamp: Date
|
||||
|
||||
public init(text: String, timestamp: Date) {
|
||||
self.text = text
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public actor HookExecutor {
|
||||
private let config: SwabbleConfig
|
||||
private var lastRun: Date?
|
||||
private let hostname: String
|
||||
|
||||
public init(config: SwabbleConfig) {
|
||||
self.config = config
|
||||
hostname = Host.current().localizedName ?? "host"
|
||||
}
|
||||
|
||||
public func shouldRun() -> Bool {
|
||||
guard config.hook.cooldownSeconds > 0 else { return true }
|
||||
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public func run(job: HookJob) async throws {
|
||||
guard shouldRun() else { return }
|
||||
guard !config.hook.command.isEmpty else { throw NSError(
|
||||
domain: "Hook",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||
|
||||
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
|
||||
let payload = prefix + job.text
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: config.hook.command)
|
||||
process.arguments = config.hook.args + [payload]
|
||||
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["SWABBLE_TEXT"] = job.text
|
||||
env["SWABBLE_PREFIX"] = prefix
|
||||
for (k, v) in config.hook.env {
|
||||
env[k] = v
|
||||
}
|
||||
process.environment = env
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
try process.run()
|
||||
|
||||
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
process.waitUntilExit()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: timeoutNanos)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
lastRun = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@preconcurrency import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class BufferConverter {
|
||||
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
||||
enum ConverterError: Swift.Error {
|
||||
case failedToCreateConverter
|
||||
case failedToCreateConversionBuffer
|
||||
case conversionFailed(NSError?)
|
||||
}
|
||||
|
||||
private var converter: AVAudioConverter?
|
||||
|
||||
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
|
||||
let inputFormat = buffer.format
|
||||
if inputFormat == format {
|
||||
return buffer
|
||||
}
|
||||
if converter == nil || converter?.outputFormat != format {
|
||||
converter = AVAudioConverter(from: inputFormat, to: format)
|
||||
converter?.primeMethod = .none
|
||||
}
|
||||
guard let converter else { throw ConverterError.failedToCreateConverter }
|
||||
|
||||
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
||||
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
||||
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
||||
else {
|
||||
throw ConverterError.failedToCreateConversionBuffer
|
||||
}
|
||||
|
||||
var nsError: NSError?
|
||||
let consumed = Box(false)
|
||||
let inputBuffer = buffer
|
||||
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
|
||||
if consumed.value {
|
||||
statusPtr.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed.value = true
|
||||
statusPtr.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
if status == .error {
|
||||
throw ConverterError.conversionFailed(nsError)
|
||||
}
|
||||
return conversionBuffer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
case transcriberUnavailable
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
private var engine = AVAudioEngine()
|
||||
private var transcriber: SpeechTranscriber?
|
||||
private var analyzer: SpeechAnalyzer?
|
||||
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
|
||||
private var resultTask: Task<Void, Never>?
|
||||
private let converter = BufferConverter()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||
let auth = await requestAuthorizationIfNeeded()
|
||||
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
||||
|
||||
let transcriberModule = SpeechTranscriber(
|
||||
locale: Locale(identifier: localeIdentifier),
|
||||
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [.volatileResults],
|
||||
attributeOptions: [])
|
||||
transcriber = transcriberModule
|
||||
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
||||
else {
|
||||
throw SpeechPipelineError.analyzerFormatUnavailable
|
||||
}
|
||||
|
||||
analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||||
inputContinuation = continuation
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
let boxed = UnsafeBuffer(buffer: buffer)
|
||||
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
try await analyzer?.start(inputSequence: stream)
|
||||
|
||||
guard let transcriberForStream = transcriber else {
|
||||
throw SpeechPipelineError.transcriberUnavailable
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
self.resultTask = Task {
|
||||
do {
|
||||
for try await result in transcriberForStream.results {
|
||||
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
||||
continuation.yield(seg)
|
||||
}
|
||||
} catch {
|
||||
// swallow errors and finish
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
resultTask?.cancel()
|
||||
inputContinuation?.finish()
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||
}
|
||||
|
||||
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
||||
do {
|
||||
let converted = try converter.convert(buffer, to: targetFormat)
|
||||
let input = AnalyzerInput(buffer: converted)
|
||||
inputContinuation?.yield(input)
|
||||
} catch {
|
||||
// drop on conversion failure
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
|
||||
let current = SFSpeechRecognizer.authorizationStatus()
|
||||
guard current == .notDetermined else { return current }
|
||||
return await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
extension AttributedString {
|
||||
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
let string = String(characters)
|
||||
tokenizer.string = string
|
||||
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
|
||||
(
|
||||
$0,
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!)
|
||||
}
|
||||
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
||||
let sentence = self[sentenceRange]
|
||||
guard let maxLength, sentence.characters.count > maxLength else {
|
||||
return [sentenceRange]
|
||||
}
|
||||
|
||||
let wordTokenizer = NLTokenizer(unit: .word)
|
||||
wordTokenizer.string = string
|
||||
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!
|
||||
}
|
||||
guard !wordRanges.isEmpty else { return [sentenceRange] }
|
||||
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
|
||||
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
|
||||
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
return ranges.compactMap { range in
|
||||
let audioTimeRanges = self[range].runs.filter {
|
||||
!String(self[$0.range].characters)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}.compactMap(\.audioTimeRange)
|
||||
guard !audioTimeRanges.isEmpty else { return nil }
|
||||
let start = audioTimeRanges.first!.start
|
||||
let end = audioTimeRanges.last!.end
|
||||
var attributes = AttributeContainer()
|
||||
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
||||
start: start,
|
||||
end: end)
|
||||
return AttributedString(self[range].characters, attributes: attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
|
||||
case trace, debug, info, warn, error
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .trace: 0
|
||||
case .debug: 1
|
||||
case .info: 2
|
||||
case .warn: 3
|
||||
case .error: 4
|
||||
}
|
||||
}
|
||||
|
||||
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
|
||||
}
|
||||
|
||||
public struct Logger: Sendable {
|
||||
public let level: LogLevel
|
||||
|
||||
public init(level: LogLevel) { self.level = level }
|
||||
|
||||
public func log(_ level: LogLevel, _ message: String) {
|
||||
guard level >= self.level else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
|
||||
}
|
||||
|
||||
public func trace(_ msg: String) { log(.trace, msg) }
|
||||
public func debug(_ msg: String) { log(.debug, msg) }
|
||||
public func info(_ msg: String) { log(.info, msg) }
|
||||
public func warn(_ msg: String) { log(.warn, msg) }
|
||||
public func error(_ msg: String) { log(.error, msg) }
|
||||
}
|
||||
|
||||
extension LogLevel {
|
||||
public init?(configValue: String) {
|
||||
self.init(rawValue: configValue.lowercased())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
public enum OutputFormat: String {
|
||||
case txt
|
||||
case srt
|
||||
|
||||
public var needsAudioTimeRange: Bool {
|
||||
switch self {
|
||||
case .srt: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||
switch self {
|
||||
case .txt:
|
||||
return String(transcript.characters)
|
||||
case .srt:
|
||||
func format(_ timeInterval: TimeInterval) -> String {
|
||||
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
|
||||
let s = Int(timeInterval) % 60
|
||||
let m = (Int(timeInterval) / 60) % 60
|
||||
let h = Int(timeInterval) / 60 / 60
|
||||
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
||||
}
|
||||
|
||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
||||
CMTimeRange,
|
||||
String)? in
|
||||
guard let timeRange = sentence.audioTimeRange else { return nil }
|
||||
return (timeRange, String(sentence.characters))
|
||||
}.enumerated().map { index, run in
|
||||
let (timeRange, text) = run
|
||||
return """
|
||||
|
||||
\(index + 1)
|
||||
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
|
||||
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
"""
|
||||
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public actor TranscriptsStore {
|
||||
public static let shared = TranscriptsStore()
|
||||
|
||||
private var entries: [String] = []
|
||||
private let limit = 100
|
||||
private let fileURL: URL
|
||||
|
||||
public init() {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func append(text: String) {
|
||||
entries.append(text)
|
||||
if entries.count > limit {
|
||||
entries.removeFirst(entries.count - limit)
|
||||
}
|
||||
let body = entries.joined(separator: "\n")
|
||||
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func latest() -> [String] { entries }
|
||||
}
|
||||
|
||||
extension String {
|
||||
private func appendLine(to url: URL) throws {
|
||||
let data = (self + "\n").data(using: .utf8) ?? Data()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: data)
|
||||
try handle.close()
|
||||
} else {
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
public struct WakeWordSegment: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let start: TimeInterval
|
||||
public let duration: TimeInterval
|
||||
public let range: Range<String.Index>?
|
||||
|
||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
||||
self.text = text
|
||||
self.start = start
|
||||
self.duration = duration
|
||||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { self.start + self.duration }
|
||||
}
|
||||
|
||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public var triggers: [String]
|
||||
public var minPostTriggerGap: TimeInterval
|
||||
public var minCommandLength: Int
|
||||
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeWordGateMatch: Sendable, Equatable {
|
||||
public let triggerEndTime: TimeInterval
|
||||
public let postGap: TimeInterval
|
||||
public let command: String
|
||||
|
||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
||||
self.triggerEndTime = triggerEndTime
|
||||
self.postGap = postGap
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public enum WakeWordGate {
|
||||
private struct Token {
|
||||
let normalized: String
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
let range: Range<String.Index>?
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct TriggerTokens {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
guard count > 0, tokens.count > count else { continue }
|
||||
for i in 0...(tokens.count - count - 1) {
|
||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
let nextToken = tokens[i + count]
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
guard let best else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
}
|
||||
|
||||
public static func commandText(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggerEndTime: TimeInterval)
|
||||
-> String {
|
||||
let threshold = triggerEndTime + 0.001
|
||||
for segment in segments where segment.start >= threshold {
|
||||
if normalizeToken(segment.text).isEmpty { continue }
|
||||
if let range = segment.range {
|
||||
let slice = transcript[range.lowerBound...]
|
||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let text = segments
|
||||
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let normalized = text.lowercased()
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if normalized.contains(token) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||
var out = text
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||
var output: [TriggerTokens] = []
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(tokens: tokens))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = self.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return Token(
|
||||
normalized: normalized,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
range: segment.range,
|
||||
text: segment.text)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
||||
.union(.punctuationCharacters)
|
||||
}
|
||||
|
||||
#if canImport(Speech)
|
||||
import Speech
|
||||
|
||||
public enum WakeWordSpeechSegments {
|
||||
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
|
||||
transcription.segments.map { segment in
|
||||
let range = Range(segment.substringRange, in: transcript)
|
||||
return WakeWordSegment(
|
||||
text: segment.substring,
|
||||
start: segment.timestamp,
|
||||
duration: segment.duration,
|
||||
range: range)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,71 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
enum CLIRegistry {
|
||||
static var descriptors: [CommandDescriptor] {
|
||||
let serveDesc = descriptor(for: ServeCommand.self)
|
||||
let transcribeDesc = descriptor(for: TranscribeCommand.self)
|
||||
let testHookDesc = descriptor(for: TestHookCommand.self)
|
||||
let micList = descriptor(for: MicList.self)
|
||||
let micSet = descriptor(for: MicSet.self)
|
||||
let micRoot = CommandDescriptor(
|
||||
name: "mic",
|
||||
abstract: "Microphone management",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [micList, micSet])
|
||||
let serviceRoot = CommandDescriptor(
|
||||
name: "service",
|
||||
abstract: "launchd helper",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
let healthDesc = descriptor(for: HealthCommand.self)
|
||||
let tailLogDesc = descriptor(for: TailLogCommand.self)
|
||||
let startDesc = descriptor(for: StartCommand.self)
|
||||
let stopDesc = descriptor(for: StopCommand.self)
|
||||
let restartDesc = descriptor(for: RestartCommand.self)
|
||||
let statusDesc = descriptor(for: StatusCommand.self)
|
||||
|
||||
let rootSignature = CommandSignature().withStandardRuntimeFlags()
|
||||
let root = CommandDescriptor(
|
||||
name: "swabble",
|
||||
abstract: "Speech hook daemon",
|
||||
discussion: "Local wake-word → SpeechTranscriber → hook",
|
||||
signature: rootSignature,
|
||||
subcommands: [
|
||||
serveDesc,
|
||||
transcribeDesc,
|
||||
testHookDesc,
|
||||
micRoot,
|
||||
serviceRoot,
|
||||
doctorDesc,
|
||||
setupDesc,
|
||||
healthDesc,
|
||||
tailLogDesc,
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
|
||||
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
|
||||
return CommandDescriptor(
|
||||
name: type.commandDescription.commandName ?? "",
|
||||
abstract: type.commandDescription.abstract,
|
||||
discussion: type.commandDescription.discussion,
|
||||
signature: sig,
|
||||
subcommands: [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct DoctorCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let auth = await SFSpeechRecognizer.authorizationStatus()
|
||||
print("Speech auth: \(auth)")
|
||||
do {
|
||||
_ = try ConfigLoader.load(at: configURL)
|
||||
print("Config: OK")
|
||||
} catch {
|
||||
print("Config missing or invalid; run setup")
|
||||
}
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
print("Mics found: \(session.devices.count)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct HealthCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "health", abstract: "Health probe")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("ok")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct MicCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "mic",
|
||||
abstract: "Microphone management",
|
||||
subcommands: [MicList.self, MicSet.self])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicList: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "list", abstract: "List input devices")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let devices = session.devices
|
||||
if devices.isEmpty { print("no audio inputs found"); return }
|
||||
for (idx, device) in devices.enumerated() {
|
||||
print("[\(idx)] \(device.localizedName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicSet: ParsableCommand {
|
||||
@Argument(help: "Device index from list") var index: Int = 0
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "set", abstract: "Set default input device index")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg = try ConfigLoader.load(at: configURL)
|
||||
cfg.audio.deviceIndex = index
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("saved device index \(index)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
import SwabbleKit
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
struct ServeCommand: ParsableCommand {
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "serve",
|
||||
abstract: "Run swabble in the foreground")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if parsed.flags.contains("noWake") { noWake = true }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg: SwabbleConfig
|
||||
do {
|
||||
cfg = try ConfigLoader.load(at: configURL)
|
||||
} catch {
|
||||
cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
}
|
||||
if noWake {
|
||||
cfg.wake.enabled = false
|
||||
}
|
||||
|
||||
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
|
||||
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
||||
let pipeline = SpeechPipeline()
|
||||
do {
|
||||
let stream = try await pipeline.start(
|
||||
localeIdentifier: cfg.speech.localeIdentifier,
|
||||
etiquette: cfg.speech.etiquetteReplacements)
|
||||
for await seg in stream {
|
||||
if cfg.wake.enabled {
|
||||
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
||||
}
|
||||
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
|
||||
let job = HookJob(text: stripped, timestamp: Date())
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: job)
|
||||
if cfg.transcripts.enabled {
|
||||
await TranscriptsStore.shared.append(text: stripped)
|
||||
}
|
||||
if seg.isFinal {
|
||||
logger.info("final: \(stripped)")
|
||||
} else {
|
||||
logger.debug("partial: \(stripped)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("serve error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? {
|
||||
configPath.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct ServiceRootCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "service",
|
||||
abstract: "Manage launchd agent",
|
||||
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
|
||||
}
|
||||
}
|
||||
|
||||
private enum LaunchdHelper {
|
||||
static let label = "com.swabble.agent"
|
||||
|
||||
static var plistURL: URL {
|
||||
FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||
}
|
||||
|
||||
static func writePlist(executable: String) throws {
|
||||
let plist: [String: Any] = [
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
}
|
||||
|
||||
static func removePlist() throws {
|
||||
try? FileManager.default.removeItem(at: plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceInstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "install", abstract: "Install user launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
|
||||
try LaunchdHelper.writePlist(executable: exe)
|
||||
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceUninstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
try LaunchdHelper.removePlist()
|
||||
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceStatus: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show launch agent status")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
|
||||
print("plist present at \(LaunchdHelper.plistURL.path)")
|
||||
} else {
|
||||
print("launchd plist not installed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct SetupCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "setup", abstract: "Write default config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct StartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("start: launchd helper not implemented; run 'swabble serve' instead")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct StopCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("stop: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RestartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("restart: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct StatusCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show daemon state")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try? ConfigLoader.load(at: configURL)
|
||||
let wake = cfg?.wake.word ?? "clawd"
|
||||
let wakeEnabled = cfg?.wake.enabled ?? false
|
||||
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
||||
print("wake: \(wakeEnabled ? wake : "disabled")")
|
||||
if latest.isEmpty {
|
||||
print("transcripts: (none yet)")
|
||||
} else {
|
||||
print("last transcripts:")
|
||||
latest.forEach { print("- \($0)") }
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TailLogCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let latest = await TranscriptsStore.shared.latest()
|
||||
for line in latest.suffix(10) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TestHookCommand: ParsableCommand {
|
||||
@Argument(help: "Text to send to hook") var text: String
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { text = positional }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try ConfigLoader.load(at: configURL)
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: HookJob(text: text, timestamp: Date()))
|
||||
print("hook invoked")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TranscribeCommand: ParsableCommand {
|
||||
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
||||
.identifier
|
||||
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
||||
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
||||
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
||||
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "transcribe",
|
||||
abstract: "Transcribe a media file locally")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { inputFile = positional }
|
||||
if let loc = parsed.options["locale"]?.last { locale = loc }
|
||||
if parsed.flags.contains("censor") { censor = true }
|
||||
if let out = parsed.options["output"]?.last { outputFile = out }
|
||||
if let fmt = parsed.options["format"]?.last { format = fmt }
|
||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let fileURL = URL(fileURLWithPath: inputFile)
|
||||
let audioFile = try AVAudioFile(forReading: fileURL)
|
||||
|
||||
let outputFormat = OutputFormat(rawValue: format) ?? .txt
|
||||
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: Locale(identifier: locale),
|
||||
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [],
|
||||
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
|
||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
|
||||
|
||||
var transcript: AttributedString = ""
|
||||
for try await result in transcriber.results {
|
||||
transcript += result.text
|
||||
}
|
||||
|
||||
let output = outputFormat.text(for: transcript, maxLength: maxLength)
|
||||
if let path = outputFile {
|
||||
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
||||
} else {
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func runCLI() async -> Int32 {
|
||||
do {
|
||||
let descriptors = CLIRegistry.descriptors
|
||||
let program = Program(descriptors: descriptors)
|
||||
let invocation = try program.resolve(argv: CommandLine.arguments)
|
||||
try await dispatch(invocation: invocation)
|
||||
return 0
|
||||
} catch {
|
||||
fputs("error: \(error)\n", stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatch(invocation: CommandInvocation) async throws {
|
||||
let parsed = invocation.parsedValues
|
||||
let path = invocation.path
|
||||
guard let first = path.first else { throw CommanderProgramError.missingCommand }
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
} else {
|
||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@Suite struct WakeWordGateTests {
|
||||
@Test func matchRequiresGapAfterTrigger() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
||||
}
|
||||
|
||||
@Test func matchAllowsGapAndExtractsCommand() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do thing")
|
||||
}
|
||||
|
||||
@Test func matchHandlesMultiWordTriggers() {
|
||||
let transcript = "hey clawd do it"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.8, 0.1),
|
||||
("it", 1.0, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSegments(
|
||||
transcript: String,
|
||||
words: [(String, TimeInterval, TimeInterval)])
|
||||
-> [WakeWordSegment] {
|
||||
var searchStart = transcript.startIndex
|
||||
var output: [WakeWordSegment] = []
|
||||
for (word, start, duration) in words {
|
||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
||||
if let range { searchStart = range.upperBound }
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Swabble
|
||||
|
||||
@Test
|
||||
func configRoundTrip() throws {
|
||||
var cfg = SwabbleConfig()
|
||||
cfg.wake.word = "robot"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
try ConfigLoader.save(cfg, at: url)
|
||||
let loaded = try ConfigLoader.load(at: url)
|
||||
#expect(loaded.wake.word == "robot")
|
||||
#expect(loaded.hook.prefix.contains("Voice swabble"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func configMissingThrows() {
|
||||
#expect(throws: ConfigError.missingConfig) {
|
||||
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
||||
|
||||
## Requirements
|
||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||
- Local only; no network calls during transcription.
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
- Foreground `serve`; later launchd helper for start/stop/restart.
|
||||
- File transcription command emitting txt or srt.
|
||||
- Basic status/health surfaces and mic selection stubs.
|
||||
|
||||
## Architecture
|
||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||
- **Logging**: simple structured logger to stderr; respects log level.
|
||||
|
||||
## Out of scope (initial cut)
|
||||
- Model management (Speech handles assets).
|
||||
- Launchd helper (planned follow-up).
|
||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
||||
|
||||
## Open decisions
|
||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta3</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta3</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2.0.0-beta4</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta4</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
@@ -0,0 +1,51 @@
|
||||
## Clawdis Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
|
||||
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
|
||||
|
||||
## Open in Android Studio
|
||||
- Open the folder `apps/android`.
|
||||
|
||||
## Build / Run
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:installDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Connect / Pair
|
||||
|
||||
1) Start the gateway (on your “master” machine):
|
||||
```bash
|
||||
pnpm clawdis gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
2) In the Android app:
|
||||
- Open **Settings**
|
||||
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
||||
|
||||
3) Approve pairing (on the gateway machine):
|
||||
```bash
|
||||
clawdis nodes pending
|
||||
clawdis nodes approve <requestId>
|
||||
```
|
||||
|
||||
More details: `docs/android/connect.md`.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Discovery:
|
||||
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
|
||||
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
|
||||
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
|
||||
- Camera:
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
@@ -0,0 +1,96 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.steipete.clawdis.node"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.steipete.clawdis.node"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.3")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.ClawdisNode">
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
enum class CameraHudKind {
|
||||
Photo,
|
||||
Recording,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class CameraHudState(
|
||||
val token: Long,
|
||||
val kind: CameraHudKind,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
object DeviceNames {
|
||||
fun bestDefaultNodeName(context: Context): String {
|
||||
val deviceName =
|
||||
runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
}
|
||||
.getOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
|
||||
if (deviceName.isNotEmpty()) return deviceName
|
||||
|
||||
val model =
|
||||
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
|
||||
return model.ifEmpty { "Android Node" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.steipete.clawdis.node.ui.RootScreen
|
||||
import com.steipete.clawdis.node.ui.ClawdisTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private lateinit var screenCaptureRequester: ScreenCaptureRequester
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
||||
applyImmersiveMode()
|
||||
requestDiscoveryPermissionsIfNeeded()
|
||||
requestNotificationPermissionIfNeeded()
|
||||
NodeForegroundService.start(this)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.preventSleep.collect { enabled ->
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
ClawdisTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
|
||||
private fun requestDiscoveryPermissionsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
|
||||
}
|
||||
} else {
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < 33) return
|
||||
val ok =
|
||||
ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!ok) {
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||
val chatMessages = runtime.chatMessages
|
||||
val chatError: StateFlow<String?> = runtime.chatError
|
||||
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||
val chatSessions = runtime.chatSessions
|
||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
runtime.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
runtime.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
runtime.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
runtime.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
runtime.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
runtime.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
runtime.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
runtime.resetWakeWordsDefaults()
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
runtime.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
runtime.connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
runtime.refreshChat()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
runtime.refreshChatSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
runtime.setChatThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
runtime.switchChatSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
runtime.abortChat()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.app.PendingIntent
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
private var lastRequiresMic = false
|
||||
private var didStartForeground = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureChannel()
|
||||
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||
|
||||
val runtime = (application as NodeApp).runtime
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.voiceWakeMode,
|
||||
runtime.voiceWakeIsListening,
|
||||
) { status, server, connected, voiceMode, voiceListening ->
|
||||
Quint(status, server, connected, voiceMode, voiceListening)
|
||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||
val voiceSuffix =
|
||||
if (voiceMode == VoiceWakeMode.Always) {
|
||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
|
||||
|
||||
val requiresMic =
|
||||
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
|
||||
startForegroundWithTypes(
|
||||
notification = buildNotification(title = title, text = text),
|
||||
requiresMic = requiresMic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).runtime.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
notificationJob?.cancel()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
private fun ensureChannel() {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Clawdis node connection status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(notification: Notification) {
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
mgr.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||
updateNotification(notification)
|
||||
return
|
||||
}
|
||||
|
||||
lastRequiresMic = requiresMic
|
||||
val types =
|
||||
if (requiresMic) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, types)
|
||||
didStartForeground = true
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "connection"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)
|
||||
@@ -0,0 +1,922 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.steipete.clawdis.node.chat.ChatController
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.BuildConfig
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
||||
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val screenRecorder = ScreenRecordManager(appContext)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
|
||||
private val voiceWake: VoiceWakeManager by lazy {
|
||||
VoiceWakeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
onCommand = { command ->
|
||||
session.sendEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive("main"))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val voiceWakeIsListening: StateFlow<Boolean>
|
||||
get() = voiceWake.isListening
|
||||
|
||||
val voiceWakeStatusText: StateFlow<String>
|
||||
get() = voiceWake.statusText
|
||||
|
||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
|
||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||
|
||||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
|
||||
private val session =
|
||||
BridgeSession(
|
||||
scope = scope,
|
||||
onConnected = { name, remote ->
|
||||
_statusText.value = "Connected"
|
||||
_serverName.value = name
|
||||
_remoteAddress.value = remote
|
||||
_isConnected.value = true
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||
onEvent = { event, payloadJson ->
|
||||
handleBridgeEvent(event, payloadJson)
|
||||
},
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
|
||||
private fun handleSessionDisconnected(message: String) {
|
||||
_statusText.value = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_isConnected.value = false
|
||||
chat.onDisconnected(message)
|
||||
showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
private var suppressWakeWordsSync = false
|
||||
private var wakeWordsSyncJob: Job? = null
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||
val chatError: StateFlow<String?> = chat.errorText
|
||||
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
voiceWakeMode,
|
||||
isForeground,
|
||||
externalAudioCaptureActive,
|
||||
wakeWords,
|
||||
) { mode, foreground, externalAudio, words ->
|
||||
Quad(mode, foreground, externalAudio, words)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (mode, foreground, externalAudio, words) ->
|
||||
voiceWake.setTriggerWords(words)
|
||||
|
||||
val shouldListen =
|
||||
when (mode) {
|
||||
VoiceWakeMode.Off -> false
|
||||
VoiceWakeMode.Foreground -> foreground
|
||||
VoiceWakeMode.Always -> true
|
||||
} && !externalAudio
|
||||
|
||||
if (!shouldListen) {
|
||||
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
|
||||
return@collect
|
||||
}
|
||||
|
||||
if (!hasRecordAudioPermission()) {
|
||||
voiceWake.stop(statusText = "Microphone permission required")
|
||||
return@collect
|
||||
}
|
||||
|
||||
voiceWake.start()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
bridges.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
val token = prefs.loadBridgeToken()
|
||||
if (token.isNullOrBlank()) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
didAutoConnect = true
|
||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
canvasDebugStatusEnabled,
|
||||
statusText,
|
||||
serverName,
|
||||
remoteAddress,
|
||||
) { debugEnabled, status, server, remote ->
|
||||
Quad(debugEnabled, status, server, remote)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (debugEnabled, status, server, remote) ->
|
||||
canvas.setDebugStatusEnabled(debugEnabled)
|
||||
if (!debugEnabled) return@collect
|
||||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.setCameraEnabled(value)
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.setPreventSleep(value)
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.setManualEnabled(value)
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
prefs.setManualHost(value)
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.setManualPort(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
scheduleWakeWordsSyncIfNeeded()
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
setWakeWords(SecurePrefs.defaultWakeWords)
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
val invokeCommands =
|
||||
buildList {
|
||||
add(ClawdisCanvasCommand.Present.rawValue)
|
||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||
add(ClawdisScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled.value) {
|
||||
add(ClawdisCameraCommand.Snap.rawValue)
|
||||
add(ClawdisCameraCommand.Clip.rawValue)
|
||||
}
|
||||
}
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
val caps = buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
}
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
BridgePairingClient.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = null,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps = caps,
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
}
|
||||
|
||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||
_statusText.value = "Failed: pairing required"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val authToken = requireNotNull(resolved.token).trim()
|
||||
prefs.saveBridgeToken(authToken)
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
val advertisedVersion =
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello =
|
||||
BridgeSession.Hello(
|
||||
nodeId = instanceId.value,
|
||||
displayName = displayName.value,
|
||||
token = authToken,
|
||||
platform = "Android",
|
||||
version = advertisedVersion,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = modelIdentifier,
|
||||
caps =
|
||||
buildList {
|
||||
add(ClawdisCapability.Canvas.rawValue)
|
||||
add(ClawdisCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(ClawdisCapability.VoiceWake.rawValue)
|
||||
}
|
||||
},
|
||||
commands = invokeCommands,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
return
|
||||
}
|
||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||
} catch (_: Throwable) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
}
|
||||
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||
|
||||
val surfaceId =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
val sourceComponentId =
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val message =
|
||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
sourceComponentId = sourceComponentId,
|
||||
host = displayName.value,
|
||||
instanceId = instanceId.value.lowercase(),
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = isConnected.value
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
session.sendEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(message))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "bridge not connected"
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.eval(
|
||||
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
error = error,
|
||||
),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
}
|
||||
|
||||
fun refreshChat() {
|
||||
chat.refresh()
|
||||
}
|
||||
|
||||
fun refreshChatSessions(limit: Int? = null) {
|
||||
chat.refreshSessions(limit = limit)
|
||||
}
|
||||
|
||||
fun setChatThinkingLevel(level: String) {
|
||||
chat.setThinkingLevel(level)
|
||||
}
|
||||
|
||||
fun switchChatSession(sessionKey: String) {
|
||||
chat.switchSession(sessionKey)
|
||||
}
|
||||
|
||||
fun abortChat() {
|
||||
chat.abort()
|
||||
}
|
||||
|
||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||
}
|
||||
|
||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
if (event == "voicewake.changed") {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
chat.handleBridgeEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
suppressWakeWordsSync = true
|
||||
prefs.setWakeWords(words)
|
||||
suppressWakeWordsSync = false
|
||||
}
|
||||
|
||||
private fun scheduleWakeWordsSyncIfNeeded() {
|
||||
if (suppressWakeWordsSync) return
|
||||
if (!_isConnected.value) return
|
||||
|
||||
val snapshot = prefs.wakeWords.value
|
||||
wakeWordsSyncJob?.cancel()
|
||||
wakeWordsSyncJob =
|
||||
scope.launch {
|
||||
delay(650)
|
||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||
val params = """{"triggers":[$jsonList]}"""
|
||||
try {
|
||||
session.request("voicewake.set", params)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = session.request("voicewake.get", "{}")
|
||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||
if (
|
||||
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
ClawdisCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
BridgeSession.InvokeResult.ok(null)
|
||||
}
|
||||
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(a2uiResetJS)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
decodeA2uiMessages(command, paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||
}
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return BridgeSession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return BridgeSession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdisCameraCommand.Snap.rawValue -> {
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camera.snap(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
ClawdisCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
|
||||
val res =
|
||||
try {
|
||||
camera.clip(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
ClawdisScreenCommand.Record.rawValue -> {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
else ->
|
||||
BridgeSession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
}
|
||||
|
||||
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
|
||||
val token = cameraHudSeq.incrementAndGet()
|
||||
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
|
||||
|
||||
if (autoHideMs != null && autoHideMs > 0) {
|
||||
scope.launch {
|
||||
delay(autoHideMs)
|
||||
if (_cameraHud.value?.token == token) _cameraHud.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
// Preserve full string for callers/logging, but keep the returned message human-friendly.
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__clawdis__/a2ui/"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexed { idx, line ->
|
||||
val el = json.parseToJsonElement(line)
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
.toList()
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
return JsonArray(out).toString()
|
||||
}
|
||||
|
||||
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
}
|
||||
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||
val matched = msg.keys.filter { allowed.contains(it) }
|
||||
if (matched.size != 1) {
|
||||
val found = msg.keys.sorted().joinToString(", ")
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
return globalThis.clawdisA2UI.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return globalThis.clawdisA2UI.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun String.toJsonString(): String {
|
||||
val escaped =
|
||||
this.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
return "\"$escaped\""
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(private val activity: ComponentActivity) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
p?.complete(result)
|
||||
}
|
||||
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
): Map<String, Boolean> =
|
||||
mutex.withLock {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) {
|
||||
launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.Default) {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
}
|
||||
|
||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSettingsDialog(permissions: List<String>) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||
}
|
||||
|
||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
|
||||
}
|
||||
|
||||
private fun permissionLabel(permission: String): String =
|
||||
when (permission) {
|
||||
Manifest.permission.CAMERA -> "Camera"
|
||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
||||
data class CaptureResult(val resultCode: Int, val data: Intent)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<CaptureResult?>? = null
|
||||
|
||||
private val launcher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
val data = result.data
|
||||
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
||||
p?.complete(CaptureResult(result.resultCode, data))
|
||||
} else {
|
||||
p?.complete(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
|
||||
mutex.withLock {
|
||||
val proceed = showRationaleDialog()
|
||||
if (!proceed) return null
|
||||
|
||||
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val intent = mgr.createScreenCaptureIntent()
|
||||
|
||||
val deferred = CompletableDeferred<CaptureResult?>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) { launcher.launch(intent) }
|
||||
|
||||
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Screen recording required")
|
||||
.setMessage("Clawdis needs to record the screen for this command.")
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
class SecurePrefs(context: Context) {
|
||||
companion object {
|
||||
val defaultWakeWords: List<String> = listOf("clawd", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val masterKey =
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs =
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"clawdis.node.secure",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
|
||||
private val _displayName =
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadBridgeToken(): String? {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun saveBridgeToken(token: String) {
|
||||
val key = "bridge.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
val array = element as? JsonArray ?: return defaultWakeWords
|
||||
val decoded =
|
||||
array.mapNotNull { item ->
|
||||
when (item) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
WakeWords.sanitize(decoded, defaultWakeWords)
|
||||
} catch (_: Throwable) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
enum class VoiceWakeMode(val rawValue: String) {
|
||||
Off("off"),
|
||||
Foreground("foreground"),
|
||||
Always("always"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): VoiceWakeMode {
|
||||
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
const val maxWordLength: Int = 64
|
||||
|
||||
fun parseCommaSeparated(input: String): List<String> {
|
||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||
val cleaned =
|
||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||
return cleaned.ifEmpty { defaults }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var i = 0
|
||||
while (i < input.length) {
|
||||
if (input[i] == '\\' && i + 3 < input.length) {
|
||||
val d0 = input[i + 1]
|
||||
val d1 = input[i + 2]
|
||||
val d2 = input[i + 3]
|
||||
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
||||
val value =
|
||||
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||
if (value in 0..255) {
|
||||
bytes.add(value.toByte())
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val codePoint = Character.codePointAt(input, i)
|
||||
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
|
||||
for (b in charBytes) {
|
||||
bytes.add(b)
|
||||
}
|
||||
i += Character.charCount(codePoint)
|
||||
}
|
||||
|
||||
return String(bytes.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.xbill.DNS.AAAARecord
|
||||
import org.xbill.DNS.ARecord
|
||||
import org.xbill.DNS.DClass
|
||||
import org.xbill.DNS.ExtendedResolver
|
||||
import org.xbill.DNS.Message
|
||||
import org.xbill.DNS.Name
|
||||
import org.xbill.DNS.PTRRecord
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.Rcode
|
||||
import org.xbill.DNS.Resolver
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.Section
|
||||
import org.xbill.DNS.SimpleResolver
|
||||
import org.xbill.DNS.TextParseException
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class BridgeDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdis-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdis.internal."
|
||||
private val logTag = "Clawdis/BridgeDiscovery"
|
||||
|
||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
||||
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private var unicastJob: Job? = null
|
||||
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||
|
||||
@Volatile private var lastWideAreaRcode: Int? = null
|
||||
@Volatile private var lastWideAreaCount: Int = 0
|
||||
|
||||
private val discoveryListener =
|
||||
object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||
override fun onDiscoveryStarted(serviceType: String) {}
|
||||
override fun onDiscoveryStopped(serviceType: String) {}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
|
||||
resolve(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById.remove(id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
startLocalDiscovery()
|
||||
startUnicastDiscovery(wideAreaDomain)
|
||||
}
|
||||
|
||||
private fun startLocalDiscovery() {
|
||||
try {
|
||||
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (true) {
|
||||
try {
|
||||
refreshUnicast(domain)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||
nsd.resolveService(
|
||||
serviceInfo,
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_bridges.value =
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
|
||||
private fun buildStatusText(): String {
|
||||
val localCount = localById.size
|
||||
val wideRcode = lastWideAreaRcode
|
||||
val wideCount = lastWideAreaCount
|
||||
|
||||
val wide =
|
||||
when (wideRcode) {
|
||||
null -> "Wide: ?"
|
||||
Rcode.NOERROR -> "Wide: $wideCount"
|
||||
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||
}
|
||||
|
||||
return when {
|
||||
localCount == 0 && wideRcode == null -> "Searching for bridges…"
|
||||
localCount == 0 -> "$wide"
|
||||
else -> "Local: $localCount • $wide"
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(serviceName: String, domain: String): String {
|
||||
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||
}
|
||||
|
||||
private fun normalizeName(raw: String): String {
|
||||
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
|
||||
|
||||
val next = LinkedHashMap<String, BridgeEndpoint>()
|
||||
for (ptr in ptrRecords) {
|
||||
val instanceFqdn = ptr.target.toString()
|
||||
val srv =
|
||||
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
?: run {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
|
||||
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||
}
|
||||
?: continue
|
||||
val port = srv.port
|
||||
if (port <= 0) continue
|
||||
|
||||
val targetFqdn = srv.target.toString()
|
||||
val host =
|
||||
resolveHostFromMessage(ptrMsg, targetFqdn)
|
||||
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
|
||||
?: resolveHostUnicast(targetFqdn)
|
||||
?: continue
|
||||
|
||||
val txtFromPtr =
|
||||
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? TXTRecord }
|
||||
val txt =
|
||||
if (txtFromPtr.isNotEmpty()) {
|
||||
txtFromPtr
|
||||
} else {
|
||||
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
|
||||
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
|
||||
}
|
||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||
}
|
||||
|
||||
unicastById.clear()
|
||||
unicastById.putAll(next)
|
||||
lastWideAreaRcode = ptrMsg.header.rcode
|
||||
lastWideAreaCount = next.size
|
||||
publish()
|
||||
|
||||
if (next.isEmpty()) {
|
||||
Log.d(
|
||||
logTag,
|
||||
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
|
||||
val suffix = "${serviceType}${domain}"
|
||||
val withoutSuffix =
|
||||
if (instanceFqdn.endsWith(suffix)) {
|
||||
instanceFqdn.removeSuffix(suffix)
|
||||
} else {
|
||||
instanceFqdn.substringBefore(serviceType)
|
||||
}
|
||||
return normalizeName(stripTrailingDot(withoutSuffix))
|
||||
}
|
||||
|
||||
private fun stripTrailingDot(raw: String): String {
|
||||
return raw.removeSuffix(".")
|
||||
}
|
||||
|
||||
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
|
||||
val query =
|
||||
try {
|
||||
Message.newQuery(
|
||||
org.xbill.DNS.Record.newRecord(
|
||||
Name.fromString(name),
|
||||
type,
|
||||
DClass.IN,
|
||||
),
|
||||
)
|
||||
} catch (_: TextParseException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val system = queryViaSystemDns(query)
|
||||
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||
|
||||
val direct = createDirectResolver() ?: return system
|
||||
return try {
|
||||
val msg = direct.send(query)
|
||||
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
|
||||
} catch (_: Throwable) {
|
||||
system
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queryViaSystemDns(query: Message): Message? {
|
||||
val network = preferredDnsNetwork()
|
||||
val bytes =
|
||||
try {
|
||||
rawQuery(network, query.toWire())
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
Message(bytes)
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
return raw.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
|
||||
val next = LinkedHashMap<String, MutableList<Record>>()
|
||||
for (r in records(msg, section)) {
|
||||
val name = r.name?.toString() ?: continue
|
||||
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
|
||||
val key = keyName(fqdn)
|
||||
val byNameAnswer = recordsByName(msg, Section.ANSWER)
|
||||
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
|
||||
if (fromAnswer != null) return fromAnswer
|
||||
|
||||
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
|
||||
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
|
||||
}
|
||||
|
||||
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
|
||||
val m = msg ?: return null
|
||||
val key = keyName(hostname)
|
||||
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
|
||||
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredDnsNetwork(): android.net.Network? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
// Prefer VPN (Tailscale) when present; otherwise use the active network.
|
||||
cm.allNetworks.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let { return it }
|
||||
|
||||
return cm.activeNetwork
|
||||
}
|
||||
|
||||
private fun createDirectResolver(): Resolver? {
|
||||
val cm = connectivity ?: return null
|
||||
|
||||
val candidateNetworks =
|
||||
buildList {
|
||||
cm.allNetworks
|
||||
.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
}?.let(::add)
|
||||
cm.activeNetwork?.let(::add)
|
||||
}.distinct()
|
||||
|
||||
val servers =
|
||||
candidateNetworks
|
||||
.asSequence()
|
||||
.flatMap { n ->
|
||||
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||
}
|
||||
.distinctBy { it.hostAddress ?: it.toString() }
|
||||
.toList()
|
||||
if (servers.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val resolvers =
|
||||
servers.mapNotNull { addr ->
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(3)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
|
||||
dns.rawQuery(
|
||||
network,
|
||||
wireQuery,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
dnsExecutor,
|
||||
signal,
|
||||
object : DnsResolver.Callback<ByteArray> {
|
||||
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||
cont.resume(answer)
|
||||
}
|
||||
|
||||
override fun onError(error: DnsResolver.DnsException) {
|
||||
cont.resumeWithException(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||
val prefix = "$key="
|
||||
for (r in records) {
|
||||
val strings: List<String> =
|
||||
try {
|
||||
r.strings.mapNotNull { it as? String }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
for (s in strings) {
|
||||
val trimmed = decodeDnsTxtString(s).trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
|
||||
val decoder =
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
return try {
|
||||
decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
} catch (_: Throwable) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveHostUnicast(hostname: String): String? {
|
||||
val a =
|
||||
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
|
||||
.mapNotNull { it as? ARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
val aaaa =
|
||||
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
|
||||
.mapNotNull { it as? AAAARecord }
|
||||
.mapNotNull { it.address?.hostAddress }
|
||||
|
||||
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
data class BridgeEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
BridgeEndpoint(
|
||||
stableId = "manual|$host|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class BridgePairingClient {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
val displayName: String?,
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
val caps: List<String>?,
|
||||
val commands: List<String>?,
|
||||
)
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
try {
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.steipete.clawdis.node.bridge
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class BridgeSession(
|
||||
private val scope: CoroutineScope,
|
||||
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
) {
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
val displayName: String?,
|
||||
val token: String?,
|
||||
val platform: String?,
|
||||
val version: String?,
|
||||
val deviceFamily: String?,
|
||||
val modelIdentifier: String?,
|
||||
val caps: List<String>?,
|
||||
val commands: List<String>?,
|
||||
)
|
||||
|
||||
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
|
||||
|
||||
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||
companion object {
|
||||
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||
fun error(code: String, message: String) =
|
||||
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||
}
|
||||
}
|
||||
|
||||
data class ErrorShape(val code: String, val message: String)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
|
||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("event"))
|
||||
put("event", JsonPrimitive(event))
|
||||
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?): String {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
|
||||
},
|
||||
)
|
||||
val res = deferred.await()
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
|
||||
val remoteAddress: String? =
|
||||
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
writeLock.withLock {
|
||||
writer.write(obj.toString())
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fun closeQuietly() {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
|
||||
private suspend fun runLoop() {
|
||||
var attempt = 0
|
||||
while (scope.isActive) {
|
||||
val target = desired
|
||||
if (target == null) {
|
||||
currentConnection?.closeQuietly()
|
||||
currentConnection = null
|
||||
delay(250)
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
|
||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||
delay(sleepMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
onConnected(name, conn.remoteAddress)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
}
|
||||
"ping" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: ""
|
||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||
}
|
||||
"res" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: continue
|
||||
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||
val payloadJson = frame["payloadJSON"].asStringOrNull()
|
||||
val error =
|
||||
frame["error"]?.let {
|
||||
val obj = it.asObjectOrNull() ?: return@let null
|
||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||
ErrorShape(code, msg)
|
||||
}
|
||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||
}
|
||||
"invoke" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: continue
|
||||
val command = frame["command"].asStringOrNull() ?: ""
|
||||
val params = frame["paramsJSON"].asStringOrNull()
|
||||
val result =
|
||||
try {
|
||||
onInvoke(InvokeRequest(id, command, params))
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
conn.sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("invoke-res"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("ok", JsonPrimitive(result.ok))
|
||||
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
if (result.error != null) {
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", JsonPrimitive(result.error.code))
|
||||
put("message", JsonPrimitive(result.error.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
val c = content.trim()
|
||||
when {
|
||||
c.equals("true", ignoreCase = true) -> true
|
||||
c.equals("false", ignoreCase = true) -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package com.steipete.clawdis.node.chat
|
||||
|
||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
class ChatController(
|
||||
private val scope: CoroutineScope,
|
||||
private val session: BridgeSession,
|
||||
private val json: Json,
|
||||
) {
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
|
||||
private val _sessionId = MutableStateFlow<String?>(null)
|
||||
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _errorText = MutableStateFlow<String?>(null)
|
||||
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||
|
||||
private val _healthOk = MutableStateFlow(false)
|
||||
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
|
||||
|
||||
private val _thinkingLevel = MutableStateFlow("off")
|
||||
val thinkingLevel: StateFlow<String> = _thinkingLevel.asStateFlow()
|
||||
|
||||
private val _pendingRunCount = MutableStateFlow(0)
|
||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||
|
||||
private val _streamingAssistantText = MutableStateFlow<String?>(null)
|
||||
val streamingAssistantText: StateFlow<String?> = _streamingAssistantText.asStateFlow()
|
||||
|
||||
private val pendingToolCallsById = ConcurrentHashMap<String, ChatPendingToolCall>()
|
||||
private val _pendingToolCalls = MutableStateFlow<List<ChatPendingToolCall>>(emptyList())
|
||||
val pendingToolCalls: StateFlow<List<ChatPendingToolCall>> = _pendingToolCalls.asStateFlow()
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSessionEntry>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSessionEntry>> = _sessions.asStateFlow()
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
|
||||
fun onDisconnected(message: String) {
|
||||
_healthOk.value = false
|
||||
// Not an error; keep connection status in the UI pill.
|
||||
_errorText.value = null
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
fun load(sessionKey: String = "main") {
|
||||
val key = sessionKey.trim().ifEmpty { "main" }
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun refreshSessions(limit: Int? = null) {
|
||||
scope.launch { fetchSessions(limit = limit) }
|
||||
}
|
||||
|
||||
fun setThinkingLevel(thinkingLevel: String) {
|
||||
val normalized = normalizeThinking(thinkingLevel)
|
||||
if (normalized == _thinkingLevel.value) return
|
||||
_thinkingLevel.value = normalized
|
||||
}
|
||||
|
||||
fun switchSession(sessionKey: String) {
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
scope.launch { bootstrap(forceHealth = true) }
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
attachments: List<OutgoingAttachment>,
|
||||
) {
|
||||
val trimmed = message.trim()
|
||||
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||
if (!_healthOk.value) {
|
||||
_errorText.value = "Gateway health not OK; cannot send"
|
||||
return
|
||||
}
|
||||
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed
|
||||
val sessionKey = _sessionKey.value
|
||||
val thinking = normalizeThinking(thinkingLevel)
|
||||
|
||||
// Optimistic user message.
|
||||
val userContent =
|
||||
buildList {
|
||||
add(ChatMessageContent(type = "text", text = text))
|
||||
for (att in attachments) {
|
||||
add(
|
||||
ChatMessageContent(
|
||||
type = att.type,
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
_messages.value =
|
||||
_messages.value +
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
armPendingRunTimeout(runId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(runId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
|
||||
_errorText.value = null
|
||||
_streamingAssistantText.value = null
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("message", JsonPrimitive(text))
|
||||
put("thinking", JsonPrimitive(thinking))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
if (attachments.isNotEmpty()) {
|
||||
put(
|
||||
"attachments",
|
||||
JsonArray(
|
||||
attachments.map { att ->
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive(att.type))
|
||||
put("mimeType", JsonPrimitive(att.mimeType))
|
||||
put("fileName", JsonPrimitive(att.fileName))
|
||||
put("content", JsonPrimitive(att.base64))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.add(actualRunId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
val runIds =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.toList()
|
||||
}
|
||||
if (runIds.isEmpty()) return
|
||||
scope.launch {
|
||||
for (runId in runIds) {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||
put("runId", JsonPrimitive(runId))
|
||||
}
|
||||
session.request("chat.abort", params.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||
when (event) {
|
||||
"tick" -> {
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
_errorText.value = "Event stream interrupted; try refreshing."
|
||||
clearPendingRuns()
|
||||
}
|
||||
"chat" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bootstrap(forceHealth: Boolean) {
|
||||
_errorText.value = null
|
||||
_healthOk.value = false
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
_sessionId.value = null
|
||||
|
||||
val key = _sessionKey.value
|
||||
try {
|
||||
try {
|
||||
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
|
||||
pollHealthIfNeeded(force = forceHealth)
|
||||
fetchSessions(limit = 50)
|
||||
} catch (err: Throwable) {
|
||||
_errorText.value = err.message
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSessions(limit: Int?) {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("includeGlobal", JsonPrimitive(true))
|
||||
put("includeUnknown", JsonPrimitive(false))
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = session.request("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
if (!force && last != null && now - last < 10_000) return
|
||||
lastHealthPollAtMs = now
|
||||
try {
|
||||
session.request("health", null)
|
||||
_healthOk.value = true
|
||||
} catch (_: Throwable) {
|
||||
_healthOk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChatEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
|
||||
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
if (runId != null) {
|
||||
val isPending =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.contains(runId)
|
||||
}
|
||||
if (!isPending) return
|
||||
}
|
||||
|
||||
val state = payload["state"].asStringOrNull()
|
||||
when (state) {
|
||||
"final", "aborted", "error" -> {
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
scope.launch {
|
||||
try {
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
val sessionId = _sessionId.value
|
||||
if (sessionId != null && runId != sessionId) return
|
||||
|
||||
val stream = payload["stream"].asStringOrNull()
|
||||
val data = payload["data"].asObjectOrNull()
|
||||
|
||||
when (stream) {
|
||||
"assistant" -> {
|
||||
val text = data?.get("text")?.asStringOrNull()
|
||||
if (!text.isNullOrEmpty()) {
|
||||
_streamingAssistantText.value = text
|
||||
}
|
||||
}
|
||||
"tool" -> {
|
||||
val phase = data?.get("phase")?.asStringOrNull()
|
||||
val name = data?.get("name")?.asStringOrNull()
|
||||
val toolCallId = data?.get("toolCallId")?.asStringOrNull()
|
||||
if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return
|
||||
|
||||
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
||||
if (phase == "start") {
|
||||
pendingToolCallsById[toolCallId] =
|
||||
ChatPendingToolCall(
|
||||
toolCallId = toolCallId,
|
||||
name = name,
|
||||
startedAtMs = ts,
|
||||
isError = null,
|
||||
)
|
||||
publishPendingToolCalls()
|
||||
} else if (phase == "result") {
|
||||
pendingToolCallsById.remove(toolCallId)
|
||||
publishPendingToolCalls()
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
_errorText.value = "Event stream interrupted; try refreshing."
|
||||
clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishPendingToolCalls() {
|
||||
_pendingToolCalls.value =
|
||||
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
||||
}
|
||||
|
||||
private fun armPendingRunTimeout(runId: String) {
|
||||
pendingRunTimeoutJobs[runId]?.cancel()
|
||||
pendingRunTimeoutJobs[runId] =
|
||||
scope.launch {
|
||||
delay(pendingRunTimeoutMs)
|
||||
val stillPending =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.contains(runId)
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRun(runId: String) {
|
||||
pendingRunTimeoutJobs.remove(runId)?.cancel()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.remove(runId)
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRuns() {
|
||||
for ((_, job) in pendingRunTimeoutJobs) {
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
array.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
|
||||
val ts = obj["timestamp"].asLongOrNull()
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = content,
|
||||
timestampMs = ts,
|
||||
)
|
||||
}
|
||||
|
||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
||||
}
|
||||
|
||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
val obj = el.asObjectOrNull() ?: return null
|
||||
val type = obj["type"].asStringOrNull() ?: "text"
|
||||
return if (type == "text") {
|
||||
ChatMessageContent(type = "text", text = obj["text"].asStringOrNull())
|
||||
} else {
|
||||
ChatMessageContent(
|
||||
type = type,
|
||||
mimeType = obj["mimeType"].asStringOrNull(),
|
||||
fileName = obj["fileName"].asStringOrNull(),
|
||||
base64 = obj["content"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? {
|
||||
return try {
|
||||
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeThinking(raw: String): String {
|
||||
return when (raw.trim().lowercase()) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asLongOrNull(): Long? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.steipete.clawdis.node.chat
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val role: String,
|
||||
val content: List<ChatMessageContent>,
|
||||
val timestampMs: Long?,
|
||||
)
|
||||
|
||||
data class ChatMessageContent(
|
||||
val type: String = "text",
|
||||
val text: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val fileName: String? = null,
|
||||
val base64: String? = null,
|
||||
)
|
||||
|
||||
data class ChatPendingToolCall(
|
||||
val toolCallId: String,
|
||||
val name: String,
|
||||
val startedAtMs: Long,
|
||||
val isError: Boolean? = null,
|
||||
)
|
||||
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
)
|
||||
|
||||
data class ChatHistory(
|
||||
val sessionKey: String,
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
)
|
||||
|
||||
data class OutgoingAttachment(
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
val fileName: String,
|
||||
val base64: String,
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.core.graphics.scale
|
||||
import com.steipete.clawdis.node.PermissionRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||
lifecycleOwner = owner
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
|
||||
if (results[Manifest.permission.CAMERA] != true) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester = permissionRequester
|
||||
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
if (results[Manifest.permission.RECORD_AUDIO] != true) {
|
||||
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snap(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
|
||||
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||
val h =
|
||||
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
decoded.scale(maxWidth, h)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
||||
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val recorder = Recorder.Builder().build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||
|
||||
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||
val recording: Recording =
|
||||
videoCapture.output
|
||||
.prepareRecording(context, outputOptions)
|
||||
.apply {
|
||||
if (includeAudio) withAudioEnabled()
|
||||
}
|
||||
.start(context.mainExecutor()) { event ->
|
||||
if (event is VideoRecordEvent.Finalize) {
|
||||
finalized.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
kotlinx.coroutines.delay(durationMs.toLong())
|
||||
} finally {
|
||||
recording.stop()
|
||||
}
|
||||
|
||||
val finalizeEvent =
|
||||
try {
|
||||
withTimeout(10_000) { finalized.await() }
|
||||
} catch (err: Throwable) {
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||
}
|
||||
if (finalizeEvent.hasError()) {
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip failed")
|
||||
}
|
||||
|
||||
val bytes = file.readBytes()
|
||||
file.delete()
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
paramsJson?.contains("\"back\"") == true -> "back"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseQuality(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
try {
|
||||
cont.resume(future.get())
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
takePicture(
|
||||
options,
|
||||
executor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
cont.resumeWithException(exception)
|
||||
}
|
||||
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
try {
|
||||
val bytes = file.readBytes()
|
||||
cont.resume(bytes)
|
||||
} catch (e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CanvasController {
|
||||
enum class SnapshotFormat(val rawValue: String) {
|
||||
Png("png"),
|
||||
Jpeg("jpeg"),
|
||||
}
|
||||
|
||||
@Volatile private var webView: WebView? = null
|
||||
@Volatile private var url: String? = null
|
||||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
private fun clampJpegQuality(quality: Double?): Int {
|
||||
val q = (quality ?: 0.82).coerceIn(0.1, 1.0)
|
||||
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
val trimmed = url.trim()
|
||||
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun setDebugStatus(title: String?, subtitle: String?) {
|
||||
debugStatusTitle = title
|
||||
debugStatusSubtitle = subtitle
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
val wv = webView ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block(wv)
|
||||
} else {
|
||||
wv.post { block(wv) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
val currentUrl = url
|
||||
withWebViewOnMain { wv ->
|
||||
if (currentUrl == null) {
|
||||
wv.loadUrl(scaffoldAssetUrl)
|
||||
} else {
|
||||
wv.loadUrl(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDebugStatus() {
|
||||
val enabled = debugStatusEnabled
|
||||
val title = debugStatusTitle
|
||||
val subtitle = debugStatusSubtitle
|
||||
withWebViewOnMain { wv ->
|
||||
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
|
||||
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
|
||||
val js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__clawdis;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
||||
}
|
||||
if (!${if (enabled) "true" else "false"}) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus($titleJs, $subtitleJs);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun eval(javaScript: String): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
suspendCancellableCoroutine { cont ->
|
||||
wv.evaluateJavascript(javaScript) { result ->
|
||||
cont.resume(result ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val width = width.coerceAtLeast(1)
|
||||
val height = height.coerceAtLeast(1)
|
||||
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||
draw(Canvas(bitmap))
|
||||
cont.resume(bitmap)
|
||||
}
|
||||
|
||||
companion object {
|
||||
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
|
||||
|
||||
fun parseNavigateUrl(paramsJson: String?): String {
|
||||
val obj = parseParamsObject(paramsJson) ?: return ""
|
||||
return obj.string("url").trim()
|
||||
}
|
||||
|
||||
fun parseEvalJs(paramsJson: String?): String? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
val js = obj.string("javaScript").trim()
|
||||
return js.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
if (!obj.containsKey("maxWidth")) return null
|
||||
val width = obj.int("maxWidth") ?: 0
|
||||
return width.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat {
|
||||
val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg
|
||||
val raw = obj.string("format").trim().lowercase()
|
||||
return when (raw) {
|
||||
"png" -> SnapshotFormat.Png
|
||||
"jpeg", "jpg" -> SnapshotFormat.Jpeg
|
||||
"" -> SnapshotFormat.Jpeg
|
||||
else -> SnapshotFormat.Jpeg
|
||||
}
|
||||
}
|
||||
|
||||
fun parseSnapshotQuality(paramsJson: String?): Double? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
if (!obj.containsKey("quality")) return null
|
||||
val q = obj.double("quality") ?: Double.NaN
|
||||
if (!q.isFinite()) return null
|
||||
return q.coerceIn(0.1, 1.0)
|
||||
}
|
||||
|
||||
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
|
||||
return SnapshotParams(
|
||||
format = parseSnapshotFormat(paramsJson),
|
||||
quality = parseSnapshotQuality(paramsJson),
|
||||
maxWidth = parseSnapshotMaxWidth(paramsJson),
|
||||
)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
return try {
|
||||
json.parseToJsonElement(raw).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonObject.string(key: String): String {
|
||||
val prim = this[key] as? JsonPrimitive ?: return ""
|
||||
val raw = prim.content
|
||||
return raw.takeIf { it != "null" }.orEmpty()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(key: String): Int? {
|
||||
val prim = this[key] as? JsonPrimitive ?: return null
|
||||
return prim.content.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(key: String): Double? {
|
||||
val prim = this[key] as? JsonPrimitive ?: return null
|
||||
return prim.content.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.steipete.clawdis.node.node
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.util.Base64
|
||||
import com.steipete.clawdis.node.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ScreenRecordManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
|
||||
|
||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||
screenCaptureRequester = requester
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun record(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Default) {
|
||||
val requester =
|
||||
screenCaptureRequester
|
||||
?: throw IllegalStateException(
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
val screenIndex = parseScreenIndex(paramsJson)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val format = parseString(paramsJson, key = "format")
|
||||
if (format != null && format.lowercase() != "mp4") {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
if (screenIndex != null && screenIndex != 0) {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
|
||||
}
|
||||
|
||||
val capture = requester.requestCapture()
|
||||
?: throw IllegalStateException(
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val mgr =
|
||||
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
|
||||
?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
|
||||
|
||||
val metrics = context.resources.displayMetrics
|
||||
val width = metrics.widthPixels
|
||||
val height = metrics.heightPixels
|
||||
val densityDpi = metrics.densityDpi
|
||||
|
||||
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
val recorder = MediaRecorder()
|
||||
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||
try {
|
||||
if (includeAudio) {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
}
|
||||
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
||||
if (includeAudio) {
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setAudioSamplingRate(44_100)
|
||||
recorder.setAudioEncodingBitRate(96_000)
|
||||
}
|
||||
recorder.setVideoSize(width, height)
|
||||
recorder.setVideoFrameRate(fpsInt)
|
||||
recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
|
||||
recorder.setOutputFile(file.absolutePath)
|
||||
recorder.prepare()
|
||||
|
||||
val surface = recorder.surface
|
||||
virtualDisplay =
|
||||
projection.createVirtualDisplay(
|
||||
"clawdis-screen",
|
||||
width,
|
||||
height,
|
||||
densityDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
recorder.start()
|
||||
delay(durationMs.toLong())
|
||||
} finally {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
recorder.reset()
|
||||
recorder.release()
|
||||
virtualDisplay?.release()
|
||||
projection.stop()
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) { file.readBytes() }
|
||||
file.delete()
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.RECORD_AUDIO,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
|
||||
val requester =
|
||||
permissionRequester
|
||||
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
|
||||
if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
|
||||
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseFps(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
|
||||
}
|
||||
|
||||
private fun parseString(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
if (!tail.startsWith('\"')) return null
|
||||
val rest = tail.drop(1)
|
||||
val end = rest.indexOf('\"')
|
||||
if (end < 0) return null
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
val raw = (pixels * fps.toLong() * 2L).toInt()
|
||||
return raw.coerceIn(1_000_000, 12_000_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object ClawdisCanvasA2UIAction {
|
||||
fun extractActionName(userAction: JsonObject): String? {
|
||||
val name =
|
||||
(userAction["name"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (name.isNotEmpty()) return name
|
||||
val action =
|
||||
(userAction["action"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return action.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
val out = StringBuilder(normalized.length)
|
||||
for (c in normalized) {
|
||||
val ok =
|
||||
c.isLetterOrDigit() ||
|
||||
c == '_' ||
|
||||
c == '-' ||
|
||||
c == '.' ||
|
||||
c == ':'
|
||||
out.append(if (ok) c else '_')
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJson: String?,
|
||||
): String {
|
||||
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||
return listOf(
|
||||
"CANVAS_A2UI",
|
||||
"action=${sanitizeTagValue(actionName)}",
|
||||
"session=${sanitizeTagValue(sessionKey)}",
|
||||
"surface=${sanitizeTagValue(surfaceId)}",
|
||||
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||
"host=${sanitizeTagValue(host)}",
|
||||
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||
"default=update_canvas",
|
||||
).joinToString(separator = " ")
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
enum class ClawdisCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
VoiceWake("voiceWake"),
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||
Present("canvas.present"),
|
||||
Hide("canvas.hide"),
|
||||
Navigate("canvas.navigate"),
|
||||
Eval("canvas.eval"),
|
||||
Snapshot("canvas.snapshot"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
||||
Push("canvas.a2ui.push"),
|
||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||
Reset("canvas.a2ui.reset"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "canvas.a2ui."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisCameraCommand(val rawValue: String) {
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "camera."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdisScreenCommand(val rawValue: String) {
|
||||
Record("screen.record"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.CameraHudKind
|
||||
import com.steipete.clawdis.node.CameraHudState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun CameraHudOverlay(
|
||||
hud: CameraHudState?,
|
||||
flashToken: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CameraFlash(token = flashToken)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = hud != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
|
||||
) {
|
||||
if (hud != null) {
|
||||
Toast(hud = hud)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraFlash(token: Long) {
|
||||
var alpha by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(token) {
|
||||
if (token == 0L) return@LaunchedEffect
|
||||
alpha = 0.85f
|
||||
delay(110)
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(alpha)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toast(hud: CameraHudState) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (hud.kind) {
|
||||
CameraHudKind.Photo -> {
|
||||
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
CameraHudKind.Recording -> {
|
||||
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
|
||||
}
|
||||
CameraHudKind.Success -> {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
}
|
||||
CameraHudKind.Error -> {
|
||||
Icon(Icons.Default.Error, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = hud.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
|
||||
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
ChatSheetContent(viewModel = viewModel)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun ClawdisTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun overlayIconColor(): Color {
|
||||
return MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootScreen(viewModel: MainViewModel) {
|
||||
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
val context = LocalContext.current
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||
|
||||
val bridgeState =
|
||||
remember(serverName, statusText) {
|
||||
when {
|
||||
serverName != null -> BridgeState.Connected
|
||||
statusText.contains("connecting", ignoreCase = true) ||
|
||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||
else -> BridgeState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
val voiceEnabled =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||
StatusPill(
|
||||
bridge = bridgeState,
|
||||
voiceEnabled = voiceEnabled,
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
||||
Column(
|
||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Chat },
|
||||
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
||||
)
|
||||
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val currentSheet = sheet
|
||||
if (currentSheet != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheet = null },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
when (currentSheet) {
|
||||
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Sheet {
|
||||
Chat,
|
||||
Settings,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayIconButton(
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = overlayContainerColor(),
|
||||
contentColor = overlayIconColor(),
|
||||
),
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
errorResponse: WebResourceResponse,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e(
|
||||
"ClawdisWebView",
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
viewModel.canvas.onPageFinished()
|
||||
}
|
||||
}
|
||||
setBackgroundColor(Color.BLACK)
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
|
||||
val a2uiBridge =
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
}
|
||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
val msg = payload?.trim().orEmpty()
|
||||
if (msg.isEmpty()) return
|
||||
onMessage(msg)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
||||
@JavascriptInterface
|
||||
fun canvasAction(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "Android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.NodeForegroundService
|
||||
import com.steipete.clawdis.node.VoiceWakeMode
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val bridges by viewModel.bridges.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||
viewModel.setCameraEnabled(cameraOk)
|
||||
}
|
||||
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// Status text is handled by NodeRuntime.
|
||||
}
|
||||
|
||||
fun setCameraEnabledChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setCameraEnabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
val cameraOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (cameraOk) {
|
||||
viewModel.setCameraEnabled(true)
|
||||
} else {
|
||||
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||
}
|
||||
}
|
||||
|
||||
val visibleBridges =
|
||||
if (isConnected && remoteAddress != null) {
|
||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||
} else {
|
||||
bridges
|
||||
}
|
||||
|
||||
val bridgeDiscoveryFooterText =
|
||||
if (visibleBridges.isEmpty()) {
|
||||
discoveryStatusText
|
||||
} else if (isConnected) {
|
||||
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
} else {
|
||||
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = viewModel::setDisplayName,
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Bridge
|
||||
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
|
||||
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
||||
if (serverName != null) {
|
||||
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
||||
}
|
||||
if (remoteAddress != null) {
|
||||
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
|
||||
}
|
||||
item {
|
||||
// UI sanity: "Disconnect" only when we have an active remote.
|
||||
if (isConnected && remoteAddress != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
NodeForegroundService.stop(context)
|
||||
},
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
if (!isConnected || visibleBridges.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) "Other Bridges" else "Discovered Bridges",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
if (!isConnected && visibleBridges.isEmpty()) {
|
||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} else {
|
||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||
ListItem(
|
||||
headlineContent = { Text(bridge.name) },
|
||||
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
NodeForegroundService.start(context)
|
||||
viewModel.connect(bridge)
|
||||
},
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
bridgeDiscoveryFooterText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Advanced") },
|
||||
supportingContent = { Text("Manual bridge connection") },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (advancedExpanded) "Collapse" else "Expand",
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
setAdvancedExpanded(!advancedExpanded)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(visible = advancedExpanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Use Manual Bridge") },
|
||||
supportingContent = { Text("Use this when discovery is blocked.") },
|
||||
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = manualHost,
|
||||
onValueChange = viewModel::setManualHost,
|
||||
label = { Text("Host") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = manualPort.toString(),
|
||||
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
|
||||
label = { Text("Port") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
|
||||
val hostOk = manualHost.trim().isNotEmpty()
|
||||
val portOk = manualPort in 1..65535
|
||||
Button(
|
||||
onClick = {
|
||||
NodeForegroundService.start(context)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
enabled = manualEnabled && hostOk && portOk,
|
||||
) {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Voice
|
||||
item { Text("Voice", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
val enabled = voiceWakeMode != VoiceWakeMode.Off
|
||||
ListItem(
|
||||
headlineContent = { Text("Voice Wake") },
|
||||
supportingContent = { Text(voiceWakeStatusText) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { on ->
|
||||
if (on) {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
} else {
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Foreground Only") },
|
||||
supportingContent = { Text("Listens only while Clawdis is open.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Always") },
|
||||
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Always,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = wakeWordsText,
|
||||
onValueChange = setWakeWordsText,
|
||||
label = { Text("Wake Words (comma-separated)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||
viewModel.setWakeWords(parsed)
|
||||
},
|
||||
enabled = isConnected,
|
||||
) {
|
||||
Text("Save + Sync")
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) {
|
||||
"Any node can edit wake words. Changes sync via the gateway bridge."
|
||||
} else {
|
||||
"Connect to a gateway to sync wake words globally."
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Camera
|
||||
item { Text("Camera", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Allow Camera") },
|
||||
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
|
||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Tip: grant Microphone permission for video clips with audio.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Screen
|
||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Prevent Sleep") },
|
||||
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
|
||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Debug
|
||||
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Debug Canvas Status") },
|
||||
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = canvasDebugStatusEnabled,
|
||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatusPill(
|
||||
bridge: BridgeState,
|
||||
voiceEnabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = overlayContainerColor(),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
modifier = Modifier.size(9.dp),
|
||||
shape = CircleShape,
|
||||
color = bridge.color,
|
||||
) {}
|
||||
|
||||
Text(
|
||||
text = bridge.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider(
|
||||
modifier = Modifier.height(14.dp).alpha(0.35f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||
tint =
|
||||
if (voiceEnabled) {
|
||||
overlayIconColor()
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class BridgeState(val title: String, val color: Color) {
|
||||
Connected("Connected", Color(0xFF2ECC71)),
|
||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||
Error("Error", Color(0xFFE74C3C)),
|
||||
Disconnected("Offline", Color(0xFF9E9E9E)),
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
errorText: String?,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
onShowSessions: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = { showThinkingMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
|
||||
}
|
||||
|
||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Message Clawd…") },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onAbort,
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = Color(0x33E74C3C),
|
||||
contentColor = Color(0xFFE74C3C),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.Stop, contentDescription = "Abort")
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(onClick = {
|
||||
val text = input
|
||||
input = ""
|
||||
onSend(text)
|
||||
}, enabled = canSend) {
|
||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorText.isNullOrBlank()) {
|
||||
Text(
|
||||
text = errorText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(7.dp),
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||
) {}
|
||||
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (healthOk) "Connected" else "Connecting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingMenuItem(
|
||||
value: String,
|
||||
current: String,
|
||||
onSet: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(thinkingLabel(value)) },
|
||||
onClick = {
|
||||
onSet(value)
|
||||
onDismiss()
|
||||
},
|
||||
trailingIcon = {
|
||||
if (value == current.trim().lowercase()) {
|
||||
Text("✓")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun thinkingLabel(raw: String): String {
|
||||
return when (raw.trim().lowercase()) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentsStrip(
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (att in attachments) {
|
||||
AttachmentChip(
|
||||
fileName = att.fileName,
|
||||
onRemove = { onRemoveAttachment(att.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||
FilledTonalIconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier.size(30.dp),
|
||||
) {
|
||||
Text("×")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String) {
|
||||
val blocks = remember(text) { splitMarkdown(text) }
|
||||
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (b in blocks) {
|
||||
when (b) {
|
||||
is ChatMarkdownBlock.Text -> {
|
||||
val trimmed = b.text.trimEnd()
|
||||
if (trimmed.isEmpty()) continue
|
||||
Text(
|
||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
is ChatMarkdownBlock.Code -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = b.code, language = b.language)
|
||||
}
|
||||
}
|
||||
is ChatMarkdownBlock.InlineImage -> {
|
||||
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ChatMarkdownBlock {
|
||||
data class Text(val text: String) : ChatMarkdownBlock
|
||||
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
|
||||
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
|
||||
}
|
||||
|
||||
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
var idx = 0
|
||||
while (idx < raw.length) {
|
||||
val fenceStart = raw.indexOf("```", startIndex = idx)
|
||||
if (fenceStart < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx)))
|
||||
break
|
||||
}
|
||||
|
||||
if (fenceStart > idx) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
|
||||
}
|
||||
|
||||
val langLineStart = fenceStart + 3
|
||||
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
|
||||
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
|
||||
|
||||
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
|
||||
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
|
||||
if (fenceEnd < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(fenceStart)))
|
||||
break
|
||||
}
|
||||
val code = raw.substring(codeStart, fenceEnd)
|
||||
out.add(ChatMarkdownBlock.Code(code = code, language = language))
|
||||
|
||||
idx = fenceEnd + 3
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
|
||||
var idx = 0
|
||||
while (idx < text.length) {
|
||||
val m = regex.find(text, startIndex = idx) ?: break
|
||||
val start = m.range.first
|
||||
val end = m.range.last + 1
|
||||
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
|
||||
|
||||
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
|
||||
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
|
||||
if (b64.isNotEmpty()) {
|
||||
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
|
||||
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
|
||||
if (text.isEmpty()) return AnnotatedString("")
|
||||
|
||||
val out = buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
if (text.startsWith("**", startIndex = i)) {
|
||||
val end = text.indexOf("**", startIndex = i + 2)
|
||||
if (end > i + 2) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append(text.substring(i + 2, end))
|
||||
}
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '`') {
|
||||
val end = text.indexOf('`', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = inlineCodeBg,
|
||||
),
|
||||
) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
|
||||
val end = text.indexOf('*', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
append(text[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image!!,
|
||||
contentDescription = mimeType ?: "image",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (failed) {
|
||||
Text(
|
||||
text = "Image unavailable",
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowCircleDown
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
|
||||
@Composable
|
||||
fun ChatMessageListCard(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
||||
val total =
|
||||
messages.size +
|
||||
(if (pendingRunCount > 0) 1 else 0) +
|
||||
(if (pendingToolCalls.isNotEmpty()) 1 else 0) +
|
||||
(if (!streamingAssistantText.isNullOrBlank()) 1 else 0)
|
||||
if (total <= 0) return@LaunchedEffect
|
||||
listState.animateScrollToItem(index = total - 1)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
|
||||
) {
|
||||
items(count = messages.size, key = { idx -> messages[idx].id }) { idx ->
|
||||
ChatMessageBubble(message = messages[idx])
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
val stream = streamingAssistantText?.trim()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||
EmptyChatHint(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChatHint(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.alpha(0.7f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowCircleDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Message Clawd…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
import com.steipete.clawdis.node.chat.ChatMessageContent
|
||||
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatMessageBubble(message: ChatMessage) {
|
||||
val isUser = message.role.lowercase() == "user"
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
color = Color.Transparent,
|
||||
modifier = Modifier.fillMaxWidth(0.92f),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(bubbleBackground(isUser))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
ChatMessageBody(content = message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageBody(content: List<ChatMessageContent>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (part in content) {
|
||||
when (part.type) {
|
||||
"text" -> {
|
||||
val text = part.text ?: continue
|
||||
ChatMarkdown(text = text)
|
||||
}
|
||||
else -> {
|
||||
val b64 = part.base64 ?: continue
|
||||
ChatBase64Image(base64 = b64, mimeType = part.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTypingIndicatorBubble() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DotPulse()
|
||||
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
for (t in toolCalls.take(6)) {
|
||||
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (toolCalls.size > 6) {
|
||||
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatStreamingAssistantBubble(text: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
ChatMarkdown(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bubbleBackground(isUser: Boolean): Brush {
|
||||
return if (isUser) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
|
||||
)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image!!,
|
||||
contentDescription = mimeType ?: "attachment",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (failed) {
|
||||
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DotPulse() {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
PulseDot(alpha = 0.38f)
|
||||
PulseDot(alpha = 0.62f)
|
||||
PulseDot(alpha = 0.90f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulseDot(alpha: Float) {
|
||||
Surface(
|
||||
modifier = Modifier.size(6.dp).alpha(alpha),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatCodeBlock(code: String, language: String?) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = code.trimEnd(),
|
||||
modifier = Modifier.padding(10.dp),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatSessionsDialog(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
onDismiss: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelect: (sessionKey: String) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Sessions", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FilledTonalIconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
if (sessions.isEmpty()) {
|
||||
Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(sessions, key = { it.key }) { entry ->
|
||||
SessionRow(
|
||||
entry = entry,
|
||||
isCurrent = entry.key == currentSessionKey,
|
||||
onClick = { onSelect(entry.key) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
entry: ChatSessionEntry,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color =
|
||||
if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isCurrent) {
|
||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.steipete.clawdis.node.ui.chat
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.MainViewModel
|
||||
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val errorText by viewModel.chatError.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
|
||||
var showSessions by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
|
||||
|
||||
val pickImages =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val next =
|
||||
uris.take(8).mapNotNull { uri ->
|
||||
try {
|
||||
loadImageAttachment(resolver, uri)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
attachments.addAll(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ChatMessageListCard(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
|
||||
ChatComposer(
|
||||
sessionKey = sessionKey,
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
errorText = errorText,
|
||||
attachments = attachments,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
onShowSessions = { showSessions = true },
|
||||
onRefresh = { viewModel.refreshChat() },
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
attachments.map { att ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
)
|
||||
}
|
||||
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||
attachments.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showSessions) {
|
||||
ChatSessionsDialog(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
onDismiss = { showSessions = false },
|
||||
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
|
||||
onSelect = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
showSessions = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PendingImageAttachment(
|
||||
val id: String,
|
||||
val fileName: String,
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
||||
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
||||
val bytes =
|
||||
withContext(Dispatchers.IO) {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
input.copyTo(out)
|
||||
out.toByteArray()
|
||||
} ?: ByteArray(0)
|
||||
}
|
||||
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
return PendingImageAttachment(
|
||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||
fileName = fileName,
|
||||
mimeType = mimeType,
|
||||
base64 = base64,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.steipete.clawdis.node.voice
|
||||
|
||||
object VoiceWakeCommandExtractor {
|
||||
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
||||
val raw = text.trim()
|
||||
if (raw.isEmpty()) return null
|
||||
|
||||
val triggers =
|
||||
triggerWords
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
if (triggers.isEmpty()) return null
|
||||
|
||||
val alternation = triggers.joinToString("|") { Regex.escape(it) }
|
||||
// Match: "<anything> <trigger><punct/space> <command>"
|
||||
val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$")
|
||||
val match = regex.find(raw) ?: return null
|
||||
val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty()
|
||||
if (extracted.isEmpty()) return null
|
||||
|
||||
val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim()
|
||||
if (cleaned.isEmpty()) return null
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
private fun Char.isPunctuation(): Boolean {
|
||||
return when (Character.getType(this)) {
|
||||
Character.CONNECTOR_PUNCTUATION.toInt(),
|
||||
Character.DASH_PUNCTUATION.toInt(),
|
||||
Character.START_PUNCTUATION.toInt(),
|
||||
Character.END_PUNCTUATION.toInt(),
|
||||
Character.INITIAL_QUOTE_PUNCTUATION.toInt(),
|
||||
Character.FINAL_QUOTE_PUNCTUATION.toInt(),
|
||||
Character.OTHER_PUNCTUATION.toInt(),
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.steipete.clawdis.node.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class VoiceWakeManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val onCommand: suspend (String) -> Unit,
|
||||
) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening: StateFlow<Boolean> = _isListening
|
||||
|
||||
private val _statusText = MutableStateFlow("Off")
|
||||
val statusText: StateFlow<String> = _statusText
|
||||
|
||||
var triggerWords: List<String> = emptyList()
|
||||
private set
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var lastDispatched: String? = null
|
||||
private var stopRequested = false
|
||||
|
||||
fun setTriggerWords(words: List<String>) {
|
||||
triggerWords = words
|
||||
}
|
||||
|
||||
fun start() {
|
||||
mainHandler.post {
|
||||
if (_isListening.value) return@post
|
||||
stopRequested = false
|
||||
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
_isListening.value = false
|
||||
_statusText.value = "Speech recognizer unavailable"
|
||||
return@post
|
||||
}
|
||||
|
||||
try {
|
||||
recognizer?.destroy()
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
startListeningInternal()
|
||||
} catch (err: Throwable) {
|
||||
_isListening.value = false
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(statusText: String = "Off") {
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
mainHandler.post {
|
||||
_isListening.value = false
|
||||
_statusText.value = statusText
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListeningInternal() {
|
||||
val r = recognizer ?: return
|
||||
val intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
|
||||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
}
|
||||
|
||||
_statusText.value = "Listening"
|
||||
_isListening.value = true
|
||||
r.startListening(intent)
|
||||
}
|
||||
|
||||
private fun scheduleRestart(delayMs: Long = 350) {
|
||||
if (stopRequested) return
|
||||
restartJob?.cancel()
|
||||
restartJob =
|
||||
scope.launch {
|
||||
delay(delayMs)
|
||||
mainHandler.post {
|
||||
if (stopRequested) return@post
|
||||
try {
|
||||
recognizer?.cancel()
|
||||
startListeningInternal()
|
||||
} catch (_: Throwable) {
|
||||
// Will be picked up by onError and retry again.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTranscription(text: String) {
|
||||
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
|
||||
if (command == lastDispatched) return
|
||||
lastDispatched = command
|
||||
|
||||
scope.launch { onCommand(command) }
|
||||
_statusText.value = "Triggered"
|
||||
scheduleRestart(delayMs = 650)
|
||||
}
|
||||
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
if (stopRequested) return
|
||||
_isListening.value = false
|
||||
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
return
|
||||
}
|
||||
|
||||
_statusText.value =
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
|
||||
SpeechRecognizer.ERROR_CLIENT -> "Client error"
|
||||
SpeechRecognizer.ERROR_NETWORK -> "Network error"
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
|
||||
SpeechRecognizer.ERROR_SERVER -> "Server error"
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
|
||||
else -> "Speech error ($error)"
|
||||
}
|
||||
scheduleRestart(delayMs = 600)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty()
|
||||
list.firstOrNull()?.let(::handleTranscription)
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty()
|
||||
list.firstOrNull()?.let(::handleTranscription)
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 148 KiB |