mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
518 Commits
codex/refa
...
josh/fix-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57350e2573 | ||
|
|
55b494610c | ||
|
|
004825a100 | ||
|
|
81dc8805b6 | ||
|
|
f94a2506d2 | ||
|
|
8db66b416b | ||
|
|
2b92fbc2ee | ||
|
|
7b659543e1 | ||
|
|
4f860bfab0 | ||
|
|
411e79d558 | ||
|
|
7d4001c855 | ||
|
|
2caf92a5b7 | ||
|
|
4747e949c7 | ||
|
|
5a251bc54c | ||
|
|
6ede75dbeb | ||
|
|
3576d1e967 | ||
|
|
003d3100c3 | ||
|
|
94e6255666 | ||
|
|
5af44a7616 | ||
|
|
00a75db428 | ||
|
|
01d3812ea2 | ||
|
|
bc4b1b018a | ||
|
|
c5b79e3b7a | ||
|
|
2a6c0ab5bf | ||
|
|
c38bbe751f | ||
|
|
68363368be | ||
|
|
d211d49011 | ||
|
|
52908649d9 | ||
|
|
331cc6a8c1 | ||
|
|
ee9a1f7eac | ||
|
|
2c7c6feb99 | ||
|
|
a3f3b043c9 | ||
|
|
edff873d31 | ||
|
|
f5194c4647 | ||
|
|
357462deef | ||
|
|
e58719503e | ||
|
|
fa65b3270e | ||
|
|
642ae61828 | ||
|
|
b816dfbb9f | ||
|
|
583b7195b4 | ||
|
|
f0b5d78ff9 | ||
|
|
f3982d6442 | ||
|
|
e8022eb4a5 | ||
|
|
49cc82e547 | ||
|
|
fd166a5318 | ||
|
|
319e41d0c5 | ||
|
|
c6b1921a91 | ||
|
|
6fa9ea08ea | ||
|
|
ef5d6a66bd | ||
|
|
920e6a8eec | ||
|
|
4aba273939 | ||
|
|
ff5d6571f2 | ||
|
|
257d540297 | ||
|
|
ae9ae560e9 | ||
|
|
ae99ce729a | ||
|
|
b9e193ce22 | ||
|
|
617c9d4b7f | ||
|
|
76658cd159 | ||
|
|
3ad3cc61b8 | ||
|
|
3b7729779a | ||
|
|
17bdd3375f | ||
|
|
cfdcd5cdfd | ||
|
|
875669e38e | ||
|
|
8d159e1ff8 | ||
|
|
2c286c3465 | ||
|
|
7bf821a2ee | ||
|
|
070685f765 | ||
|
|
33862206b4 | ||
|
|
e77fa3aeba | ||
|
|
c1df7aa08b | ||
|
|
d1e20d2f29 | ||
|
|
1469441ff4 | ||
|
|
42dcf7075f | ||
|
|
0278b59d0e | ||
|
|
62503c4b48 | ||
|
|
f78235d346 | ||
|
|
d42e557a66 | ||
|
|
2971775ead | ||
|
|
c9a854c217 | ||
|
|
f3ab59db58 | ||
|
|
91fb5d3823 | ||
|
|
91220cbd31 | ||
|
|
40eec48caf | ||
|
|
b836946879 | ||
|
|
6470bb7625 | ||
|
|
617f97d4b9 | ||
|
|
3d05da9a54 | ||
|
|
5ce413a2c7 | ||
|
|
3630ce6cbb | ||
|
|
64785823d0 | ||
|
|
6e3ebaccf0 | ||
|
|
a67ae8137d | ||
|
|
a6b348a307 | ||
|
|
f285a0c4c4 | ||
|
|
05584427a8 | ||
|
|
f046d7aa23 | ||
|
|
de1d329e31 | ||
|
|
75cdf22152 | ||
|
|
acc375ff75 | ||
|
|
e48222175f | ||
|
|
1fc04ac6e3 | ||
|
|
5939a2ac9f | ||
|
|
6656c71c7a | ||
|
|
8c7690b256 | ||
|
|
7c314e1504 | ||
|
|
6ab41d50d4 | ||
|
|
f95ca1de26 | ||
|
|
cb811d4650 | ||
|
|
45343f5d64 | ||
|
|
ab71827cf3 | ||
|
|
7e46326d21 | ||
|
|
c861730047 | ||
|
|
5dee1eefb7 | ||
|
|
8fc5911e21 | ||
|
|
d344dcbd91 | ||
|
|
4e3d2ff79b | ||
|
|
3e2e3dfa92 | ||
|
|
f11bf1ed42 | ||
|
|
47ce7bc581 | ||
|
|
e1770b041c | ||
|
|
d3c86f96af | ||
|
|
ea6704319a | ||
|
|
61b104cf73 | ||
|
|
a9df801902 | ||
|
|
6b3e23aba7 | ||
|
|
4542d3914c | ||
|
|
d24a589f1c | ||
|
|
0af07bb378 | ||
|
|
df8ceb5267 | ||
|
|
32d1ccd71c | ||
|
|
998445ea20 | ||
|
|
5cebe96667 | ||
|
|
c45c87acca | ||
|
|
a4d013a9f3 | ||
|
|
9de6a99c8f | ||
|
|
fa0116b0a0 | ||
|
|
49572863d3 | ||
|
|
c48b36a255 | ||
|
|
11b6c01198 | ||
|
|
aebf0bbd2d | ||
|
|
e567986355 | ||
|
|
c4940a4ff9 | ||
|
|
ed16f8fcf0 | ||
|
|
65805e519d | ||
|
|
fd61b1b6ee | ||
|
|
9dbc423aa4 | ||
|
|
35ffbf93b9 | ||
|
|
1881a0188b | ||
|
|
33bf9874bf | ||
|
|
ecd0d17243 | ||
|
|
e4f448c74f | ||
|
|
6163425a2d | ||
|
|
b0a2b65d81 | ||
|
|
fcdbef732c | ||
|
|
a6dd20ae9d | ||
|
|
fa33f5bbb8 | ||
|
|
a117064697 | ||
|
|
4292f0fe7f | ||
|
|
623761e5c5 | ||
|
|
8415887646 | ||
|
|
f1b6a60583 | ||
|
|
b500a488e4 | ||
|
|
645fe838ff | ||
|
|
4fee348764 | ||
|
|
0471275270 | ||
|
|
203bddcdb7 | ||
|
|
c6d549c5a7 | ||
|
|
176572cb35 | ||
|
|
55c047e77e | ||
|
|
58a8142a33 | ||
|
|
2e7caba557 | ||
|
|
0fd0e7cb92 | ||
|
|
a89e6e05ef | ||
|
|
08ff253e5f | ||
|
|
033bb86133 | ||
|
|
790e00a303 | ||
|
|
2cfcb3c932 | ||
|
|
9ed9d389e0 | ||
|
|
d697ecf172 | ||
|
|
6d22b8eb24 | ||
|
|
c14793d35a | ||
|
|
f90ec6d7be | ||
|
|
1a002c2d9d | ||
|
|
a55f625b09 | ||
|
|
21d3a70826 | ||
|
|
9b49387ad8 | ||
|
|
7e9b9421bd | ||
|
|
ff5e73539a | ||
|
|
b5648b1d5e | ||
|
|
0ab4cd7c52 | ||
|
|
042ebb4f75 | ||
|
|
1ae0eacf4b | ||
|
|
c06b7959ec | ||
|
|
aeb5b794c9 | ||
|
|
e83926747c | ||
|
|
e51c0c8cea | ||
|
|
67c55ccce8 | ||
|
|
385d1ada91 | ||
|
|
7fc124dcf1 | ||
|
|
63825369a2 | ||
|
|
f2522a535d | ||
|
|
20964d3e3b | ||
|
|
75141775db | ||
|
|
999d44340f | ||
|
|
ca1a53aca4 | ||
|
|
46c12b6c54 | ||
|
|
bbfea21a18 | ||
|
|
1e0062b44a | ||
|
|
23589d9e7c | ||
|
|
15166e81ca | ||
|
|
4c9e7f6c61 | ||
|
|
840cfd69cd | ||
|
|
b037280ea9 | ||
|
|
6aff1e8f9e | ||
|
|
e06f5f2edc | ||
|
|
d7cebdc215 | ||
|
|
53da30dd98 | ||
|
|
e46bcb834f | ||
|
|
d2439d2f7d | ||
|
|
52280351bb | ||
|
|
e1d3f12d7f | ||
|
|
ce6fd93279 | ||
|
|
1884cedd35 | ||
|
|
610c76087b | ||
|
|
a664c44375 | ||
|
|
add00d747b | ||
|
|
bb164384c2 | ||
|
|
4a0e376d1f | ||
|
|
2196ea2930 | ||
|
|
6aa83374d9 | ||
|
|
59950f7b52 | ||
|
|
ccf83ace38 | ||
|
|
2b752ac0d1 | ||
|
|
01d3505d7c | ||
|
|
37636ac8e2 | ||
|
|
5a9396ef6d | ||
|
|
e934e1cad7 | ||
|
|
5afddf547e | ||
|
|
9a0aefb73f | ||
|
|
325d0208d0 | ||
|
|
983e0f2ba0 | ||
|
|
37c1dacac9 | ||
|
|
ca5c3e677a | ||
|
|
2fec8b12d5 | ||
|
|
a89c9937c2 | ||
|
|
9bdf89598e | ||
|
|
df17e01cac | ||
|
|
9d1dec4678 | ||
|
|
350f06362b | ||
|
|
a2bc7ab269 | ||
|
|
7ac2bbaaf0 | ||
|
|
96404a7bd5 | ||
|
|
484ee14273 | ||
|
|
88c9e4d644 | ||
|
|
99a398a4b1 | ||
|
|
ef3e5f5e31 | ||
|
|
6f53f84af3 | ||
|
|
d6eefa191f | ||
|
|
481652d78a | ||
|
|
9a86a2b30b | ||
|
|
300794520b | ||
|
|
f9376b16d4 | ||
|
|
ee81082f57 | ||
|
|
395a082348 | ||
|
|
8c108c294d | ||
|
|
48d96cd8a1 | ||
|
|
d1f6ca20a1 | ||
|
|
c9418b8afd | ||
|
|
0c657190ec | ||
|
|
6ffa0fb348 | ||
|
|
0fb0c2cb8e | ||
|
|
0e71ce1174 | ||
|
|
ffa736f713 | ||
|
|
b85ae9fb1b | ||
|
|
67c80e941e | ||
|
|
e850750754 | ||
|
|
7c6ad2327c | ||
|
|
d88f1bf217 | ||
|
|
6da2d6ac5a | ||
|
|
2b05bd7b0d | ||
|
|
eea350f2ff | ||
|
|
c8c94e15ad | ||
|
|
d7a09b13e6 | ||
|
|
88e4a0f0d5 | ||
|
|
db194a6887 | ||
|
|
00160ea6ee | ||
|
|
ffb67d2d2e | ||
|
|
d89ab2c014 | ||
|
|
11a0ad10e9 | ||
|
|
9b6bed7a75 | ||
|
|
f87d194b8b | ||
|
|
386b0e6c74 | ||
|
|
ee495abda1 | ||
|
|
147e979713 | ||
|
|
1ee788189a | ||
|
|
e71cf0ffcb | ||
|
|
3c65127827 | ||
|
|
a4e7d9a0db | ||
|
|
ac8a3f367c | ||
|
|
8694fe7e81 | ||
|
|
073343e2e2 | ||
|
|
aa0d710085 | ||
|
|
c70b9849d9 | ||
|
|
919c5b7c7b | ||
|
|
5296dc378f | ||
|
|
a447f9a43d | ||
|
|
04b7e192af | ||
|
|
450060d7a2 | ||
|
|
6bc57ca73a | ||
|
|
ea346f4361 | ||
|
|
d5c9e7ea99 | ||
|
|
9eed9c5758 | ||
|
|
1c2363def6 | ||
|
|
b7d53800d6 | ||
|
|
6326395c0a | ||
|
|
568f2d5631 | ||
|
|
e94b666e45 | ||
|
|
ee3b7eb7c0 | ||
|
|
2365a137d8 | ||
|
|
dc09d148bb | ||
|
|
55263b3dfa | ||
|
|
01acb34bdb | ||
|
|
03e3ef86af | ||
|
|
eac3e08cfd | ||
|
|
a375d6c849 | ||
|
|
9dbf8f718f | ||
|
|
fd806ada64 | ||
|
|
4ca8bf086c | ||
|
|
b41c0b6746 | ||
|
|
52d9d16e1b | ||
|
|
0ef8620746 | ||
|
|
74c6f175c7 | ||
|
|
0d50ec77de | ||
|
|
eccfacb02c | ||
|
|
f08b24e63c | ||
|
|
c32ba171db | ||
|
|
e64379dddb | ||
|
|
127e174c9e | ||
|
|
21e3cfa5e9 | ||
|
|
c8e70708e9 | ||
|
|
0bb415bf66 | ||
|
|
00c58b6613 | ||
|
|
50dbd46aa6 | ||
|
|
baeedaa316 | ||
|
|
0f71a665ed | ||
|
|
bcd1fdb1db | ||
|
|
1b5ac45e60 | ||
|
|
3d3d291902 | ||
|
|
57bad4bdf8 | ||
|
|
923828ccd7 | ||
|
|
7305d9baca | ||
|
|
212fef8703 | ||
|
|
7a008c53f4 | ||
|
|
93fa065fb3 | ||
|
|
bdc3017c1a | ||
|
|
6cb631afc9 | ||
|
|
fec6a0407f | ||
|
|
43cef3a80c | ||
|
|
e32929e12c | ||
|
|
f018945eff | ||
|
|
8b0eac7927 | ||
|
|
efa9a6110b | ||
|
|
32af1c0697 | ||
|
|
b3128ba93d | ||
|
|
eb67ac5cbe | ||
|
|
ef6b7e3659 | ||
|
|
a355825060 | ||
|
|
2cc25aa909 | ||
|
|
2758140607 | ||
|
|
55d1324c7d | ||
|
|
dc573a38dc | ||
|
|
9dbe25e0f6 | ||
|
|
e3ca10438a | ||
|
|
68cbd1ae63 | ||
|
|
0ea08076c3 | ||
|
|
04d8a96b6c | ||
|
|
5c2487dc9a | ||
|
|
c40db057da | ||
|
|
df521a6459 | ||
|
|
a0b16f37e8 | ||
|
|
cc000e0ffe | ||
|
|
9092578d8d | ||
|
|
a23de348b2 | ||
|
|
7650397a22 | ||
|
|
5a2641fc41 | ||
|
|
377f6181a9 | ||
|
|
b896e22e10 | ||
|
|
1ffda5d3ca | ||
|
|
ec2788cf80 | ||
|
|
379de52b59 | ||
|
|
0944045b75 | ||
|
|
c932bf377b | ||
|
|
ddb07fc597 | ||
|
|
caab343461 | ||
|
|
8e55348ff9 | ||
|
|
8e56cf591d | ||
|
|
40f1190c8b | ||
|
|
8ded756284 | ||
|
|
f00de6b06a | ||
|
|
158d3db85a | ||
|
|
6cd6e3f39e | ||
|
|
4bb4252042 | ||
|
|
95da644f6d | ||
|
|
6451550cd7 | ||
|
|
10a4c7c10b | ||
|
|
fa9b8c8c6b | ||
|
|
f5a9456219 | ||
|
|
7208567382 | ||
|
|
b90f7d2fd0 | ||
|
|
1e2363b687 | ||
|
|
c082a3b740 | ||
|
|
53541b2141 | ||
|
|
a3070e3ddf | ||
|
|
865e4db1cd | ||
|
|
31a10ee388 | ||
|
|
a34ddce9a2 | ||
|
|
f6a3ac7e58 | ||
|
|
95772c8541 | ||
|
|
85a635368e | ||
|
|
0888debcd3 | ||
|
|
794bd89fa0 | ||
|
|
93b7e3d717 | ||
|
|
da92615816 | ||
|
|
767e8280ac | ||
|
|
c1219d161d | ||
|
|
636aab6891 | ||
|
|
8f9493c213 | ||
|
|
826ea2bf85 | ||
|
|
0bbac63d00 | ||
|
|
1fef20c96b | ||
|
|
77d6ad6f65 | ||
|
|
206ac169e6 | ||
|
|
66079161d7 | ||
|
|
dd7f2ef002 | ||
|
|
018d279468 | ||
|
|
a09f6b1b27 | ||
|
|
d1b33a6040 | ||
|
|
8682d0701b | ||
|
|
4029fbd2b2 | ||
|
|
e7ee1c55b4 | ||
|
|
3ce3ed668d | ||
|
|
0314819f91 | ||
|
|
eab22a911a | ||
|
|
773ffd87a1 | ||
|
|
3add8af427 | ||
|
|
3fc850fe86 | ||
|
|
ba1be23821 | ||
|
|
233b48daaa | ||
|
|
501f63443f | ||
|
|
dae2bcf31b | ||
|
|
aaa5ce6280 | ||
|
|
6e8982f7a0 | ||
|
|
8ea848acb0 | ||
|
|
59e6452772 | ||
|
|
e573b751bf | ||
|
|
d012d29e6f | ||
|
|
44bf1c6d72 | ||
|
|
7a7165ad22 | ||
|
|
928b5932a3 | ||
|
|
77a682c5de | ||
|
|
efbefceb0e | ||
|
|
1c30bb8ce6 | ||
|
|
94833b2c90 | ||
|
|
1057e74438 | ||
|
|
55a6d8c57d | ||
|
|
b8967fc877 | ||
|
|
42759a1b79 | ||
|
|
5b18b7560e | ||
|
|
bcb016a528 | ||
|
|
b3f315461b | ||
|
|
5b460c4669 | ||
|
|
1f8c4d3958 | ||
|
|
04875efd28 | ||
|
|
7e0128ae65 | ||
|
|
1db8ab3734 | ||
|
|
cc954798f2 | ||
|
|
4fc805320f | ||
|
|
06431fd99b | ||
|
|
3d38c9a633 | ||
|
|
663fabbe30 | ||
|
|
c847db550f | ||
|
|
50c82b3020 | ||
|
|
dc46a67e80 | ||
|
|
7d8b000bf7 | ||
|
|
ac1042b09b | ||
|
|
fd80e0dd6b | ||
|
|
44e6caff54 | ||
|
|
d1eda0bd6f | ||
|
|
cae64ee360 | ||
|
|
e8db9c3bc0 | ||
|
|
e82d19fb06 | ||
|
|
870ec6dee2 | ||
|
|
fef8394079 | ||
|
|
1ca3d4f586 | ||
|
|
02acb0bd74 | ||
|
|
a1f18ef46e | ||
|
|
9ba6ed1d5c | ||
|
|
f1b8827d20 | ||
|
|
356385045f | ||
|
|
a189baa4b3 | ||
|
|
07dfdd4bd0 | ||
|
|
96f786d4b1 | ||
|
|
0100c27bc0 | ||
|
|
a8ca9619a5 | ||
|
|
3a8d422000 | ||
|
|
fc3e00cc11 | ||
|
|
a1b7f3570c | ||
|
|
b8ed2c3280 | ||
|
|
8bc728307d | ||
|
|
81924cfd5e | ||
|
|
6134c00657 | ||
|
|
4c2fef4a3b | ||
|
|
b470316fc0 | ||
|
|
7e12a3326d | ||
|
|
a42bda5b37 | ||
|
|
ecaebfc51b | ||
|
|
364461949d | ||
|
|
10b0dea77a |
@@ -24,7 +24,7 @@ Use when:
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
|
||||
@@ -43,6 +43,42 @@ Use when:
|
||||
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Scope Governor
|
||||
|
||||
Autoreview is a closeout gate, not permission to rewrite the task.
|
||||
|
||||
Before the first review, freeze a scope baseline: original request or issue, target branch, intended behavior, owner boundary, changed files, and non-test LOC. For inherited or already-bloated branches, use the intended PR diff as the baseline rather than accepting all existing branch drift.
|
||||
|
||||
Before patching a finding, classify it:
|
||||
|
||||
- **In-scope blocker**: the finding is introduced by the current diff, affects the same owner boundary, and can be fixed without changing the task's contract.
|
||||
- **Follow-up**: the finding is real but belongs to an adjacent bug class, sibling surface, cleanup, or broader hardening track.
|
||||
- **Stop-and-escalate**: the finding requires a new protocol/config/storage/public API contract, a different owner boundary, a release-process change, or a design choice outside the original request.
|
||||
|
||||
Stop patching and report the scope break instead of continuing when:
|
||||
|
||||
- a narrow PR turns into an architecture change, protocol change, migration, or release-process change;
|
||||
- the diff grows past 2x the original files or non-test LOC without explicit approval to expand scope;
|
||||
- two review-triggered patch cycles have not converged; pause and reclassify every remaining finding before another edit;
|
||||
- the best fix is "define the canonical contract first" rather than another local inference layer;
|
||||
- fixing the accepted finding would make the PR no longer describe the same behavior, issue, or owner boundary.
|
||||
|
||||
After the two-cycle pause, continue only when every remaining accepted finding is still an in-scope blocker. Otherwise preserve the useful analysis, identify the smallest safe landed subset if one exists, and open or request a follow-up for the larger fix. Do not keep committing speculative fixes just to satisfy the reviewer.
|
||||
|
||||
Do not stack or push review-triggered fix commits while scope classification or focused proof is unresolved. Keep exploratory edits local until the cycle is proven in scope; if scope breaks, remove them from the landing lane instead of preserving them as branch history.
|
||||
|
||||
Critical exceptions must be explicit: active data loss, crash, broken install/upgrade, release blocker, or concrete security exposure. If the exception is not one of those, it is not critical enough to blow up scope.
|
||||
|
||||
## Release Branches And Release Process
|
||||
|
||||
On release, beta, stable, hotfix, signing, notarization, appcast, package-publish, or release-check work, use freeze discipline even when the branch name is not release-like:
|
||||
|
||||
- Fix only release blockers, failed release infrastructure, exact backports, install/upgrade breakage, data loss, crashes, or concrete security exposure.
|
||||
- Treat non-blocking autoreview findings as follow-ups for `main`, not reasons to broaden the release branch.
|
||||
- Do not introduce new product behavior, config surface, protocol shape, migration, plugin ownership, docs narrative, or process policy unless it directly unblocks the release.
|
||||
- Keep proof tied to the release target: exact branch/ref, failing check or shipped-risk reason, smallest command/proof, and whether the fix must also forward-port to `main`.
|
||||
- If review discovers a real but non-critical design problem during release closeout, stop with a follow-up issue/PR plan; do not use the release branch as the refactor lane.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
@@ -440,8 +440,36 @@ def load_datasets(args: argparse.Namespace) -> str:
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
def review_scope_policy() -> str:
|
||||
return textwrap.dedent(
|
||||
"""
|
||||
Review scope discipline:
|
||||
- This helper is a closeout gate. Do not turn a narrow patch into a broad
|
||||
redesign request.
|
||||
- Report a finding only when this diff introduces or exposes a concrete
|
||||
defect that must be fixed before this target can land.
|
||||
- If the best fix requires a new protocol, config, storage, public API,
|
||||
release process, migration, owner-boundary move, or canonical contract,
|
||||
say that directly in the finding and keep the finding tied to the
|
||||
smallest changed line that proves the current patch is not landable.
|
||||
- Do not ask for sibling-surface hardening, cleanup, refactors, or
|
||||
follow-up architecture work unless the current diff is incorrect
|
||||
without that work.
|
||||
- Prefer the smallest correct pre-merge fix. A broader ideal design is
|
||||
not an actionable finding unless the current patch cannot safely land.
|
||||
- If this is release-branch or release-process work, apply freeze
|
||||
discipline. Report only release blockers, exact backport regressions,
|
||||
install/upgrade breakage, crashes, data loss, concrete security
|
||||
exposure, or release-infrastructure failures. Non-blocking design,
|
||||
cleanup, and hardening concerns belong on main as follow-ups.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str:
|
||||
target_line = f"{target} {target_ref}" if target_ref else target
|
||||
branch = current_branch(repo)
|
||||
scope_policy = review_scope_policy()
|
||||
return textwrap.dedent(
|
||||
f"""
|
||||
You are a senior code reviewer. Review the provided git change bundle only.
|
||||
@@ -463,8 +491,11 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
|
||||
- If there are no actionable findings, return an empty findings array and mark the patch correct.
|
||||
|
||||
Review target: {target_line}
|
||||
Current branch: {branch}
|
||||
Repository: {repo}
|
||||
|
||||
{scope_policy}
|
||||
|
||||
{extra_prompt}
|
||||
|
||||
{datasets}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -145,8 +146,23 @@ def create_fixture_repo(repo: Path, fixture: str) -> None:
|
||||
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
|
||||
|
||||
|
||||
def validate_prompt_policy(repo: Path, autoreview: Path) -> None:
|
||||
namespace = runpy.run_path(str(autoreview))
|
||||
prompt = namespace["build_prompt"](repo, "local", None, "fixture diff", "", "")
|
||||
required = (
|
||||
"This helper is a closeout gate.",
|
||||
"Do not turn a narrow patch into a broad",
|
||||
"If this is release-branch or release-process work",
|
||||
"Non-blocking design,",
|
||||
)
|
||||
missing = [needle for needle in required if needle not in prompt]
|
||||
if missing:
|
||||
raise RuntimeError(f"autoreview prompt missing scope policy: {missing}")
|
||||
|
||||
|
||||
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
|
||||
autoreview = script_dir / "autoreview"
|
||||
validate_prompt_policy(repo, autoreview)
|
||||
for engine in engines:
|
||||
print(f"== {engine} ==", flush=True)
|
||||
command = [
|
||||
|
||||
51
.agents/skills/discord-user-post/SKILL.md
Normal file
51
.agents/skills/discord-user-post/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: discord-user-post
|
||||
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
|
||||
---
|
||||
|
||||
# Discord User Post
|
||||
|
||||
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
|
||||
existing logged-in session. This workflow represents the user directly.
|
||||
|
||||
## Prepare
|
||||
|
||||
1. Draft the complete final message outside Discord.
|
||||
2. Confirm the intended server and channel with the user when either is
|
||||
ambiguous.
|
||||
3. Open Discord and navigate to the exact destination without entering the
|
||||
message.
|
||||
4. Verify the visible server name, channel header, and logged-in account.
|
||||
|
||||
Do not infer the target from unrelated Discord content. Stop if Discord is not
|
||||
logged in, the account is wrong, or the exact destination cannot be verified.
|
||||
|
||||
## Confirm and Post
|
||||
|
||||
Posting is representational communication. Follow the `$computer-use`
|
||||
confirmation policy even when the user previously asked for an announcement:
|
||||
|
||||
1. Show the user the exact final body and verified destination.
|
||||
2. Request action-time confirmation before typing into Discord.
|
||||
3. After confirmation, enter the approved body unchanged.
|
||||
4. Visually inspect the composed message and destination again.
|
||||
5. Send once.
|
||||
|
||||
If the body or destination changes after confirmation, request confirmation
|
||||
again before sending.
|
||||
|
||||
## Verify
|
||||
|
||||
- Confirm the message appears once, from the user's account, in the intended
|
||||
channel.
|
||||
- Report the server, channel, and visible send result.
|
||||
- Do not edit, delete, react, or send a follow-up without the corresponding
|
||||
user instruction and confirmation.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
|
||||
or token for this workflow.
|
||||
- Never expose private Discord content or account details in public output.
|
||||
- Never send a draft, partial message, duplicate, or unreviewed attachment.
|
||||
- For Discord archive/history/search, use `$discrawl` instead.
|
||||
4
.agents/skills/discord-user-post/agents/openai.yaml
Normal file
4
.agents/skills/discord-user-post/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Discord User Post"
|
||||
short_description: "Post approved messages through the logged-in Discord app"
|
||||
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."
|
||||
@@ -284,7 +284,7 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- When an agent is landing or merging a PR targeting `main`, use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
|
||||
@@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
|
||||
- `docs/help/testing.md`
|
||||
- `docs/channels/qa-channel.md`
|
||||
- `qa/README.md`
|
||||
- `qa/scenarios/index.md`
|
||||
- `qa/scenarios/index.yaml`
|
||||
- `extensions/qa-lab/src/suite.ts`
|
||||
- `extensions/qa-lab/src/character-eval.ts`
|
||||
|
||||
@@ -198,7 +198,9 @@ pnpm openclaw qa character-eval \
|
||||
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
|
||||
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
|
||||
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
|
||||
- Scenario source should stay markdown-driven under `qa/scenarios/`.
|
||||
- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and
|
||||
per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional
|
||||
`flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files.
|
||||
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
|
||||
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
|
||||
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
|
||||
@@ -234,7 +236,8 @@ pnpm openclaw qa manual \
|
||||
|
||||
## Repo facts
|
||||
|
||||
- Seed scenarios live in `qa/`.
|
||||
- Seed scenarios live in `qa/scenarios/index.yaml` and
|
||||
`qa/scenarios/<theme>/*.yaml`.
|
||||
- Main live runner: `extensions/qa-lab/src/suite.ts`
|
||||
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
|
||||
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
|
||||
@@ -262,8 +265,9 @@ pnpm openclaw qa manual \
|
||||
|
||||
## When adding scenarios
|
||||
|
||||
- Add or update scenario markdown under `qa/scenarios/`
|
||||
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
|
||||
- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario
|
||||
files or fenced YAML blocks.
|
||||
- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned
|
||||
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
|
||||
- Prefer end-to-end assertions over mock-only checks
|
||||
- Save outputs under `.artifacts/qa-e2e/`
|
||||
|
||||
@@ -6,7 +6,8 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
|
||||
# OpenClaw Release Announcement
|
||||
|
||||
Use with `release-openclaw-maintainer` after a beta or stable release is live.
|
||||
Use with `openclaw-discord` when actually posting to Discord.
|
||||
Use with `$discord-user-post` when actually posting to Discord as the logged-in
|
||||
user.
|
||||
|
||||
## Evidence First
|
||||
|
||||
@@ -80,6 +81,7 @@ Fresh installs still point to `https://openclaw.ai`.
|
||||
|
||||
## Posting
|
||||
|
||||
When asked to post, use the configured Discord workflow from
|
||||
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
|
||||
For public channels, inspect the final body before sending.
|
||||
When asked to post, use `$discord-user-post` to operate the logged-in Discord
|
||||
desktop app as the user. Resolve and visibly verify the exact server/channel,
|
||||
inspect the final body, and request action-time confirmation before entering or
|
||||
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.
|
||||
|
||||
@@ -16,6 +16,10 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
||||
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
|
||||
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
|
||||
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
|
||||
- A model-list response proves authentication, not billing or inference
|
||||
entitlement. Mandatory live providers must pass a real completion probe
|
||||
before release dispatch. Fix the credential first; do not add an alternate
|
||||
auth path merely to bypass a failed release credential.
|
||||
- Full Release Validation parent monitors fail fast: once a required child job
|
||||
fails, the parent cancels the remaining child matrix and prints the failed
|
||||
job summary. Inspect that first red job instead of waiting for unrelated
|
||||
@@ -36,6 +40,8 @@ git rev-parse HEAD
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
The Anthropic check performs a tiny message completion so exhausted or
|
||||
non-billable credentials fail before the expensive release matrix.
|
||||
|
||||
## Dispatch
|
||||
|
||||
@@ -65,6 +71,13 @@ gh workflow run openclaw-performance.yml \
|
||||
|
||||
Prefer the trusted workflow on `main`, target the exact release SHA:
|
||||
|
||||
- Keep trusted-workflow checks compatible with frozen release targets. If
|
||||
`main` adds a target-owned guard script or package command after the release
|
||||
branch cut, make the trusted workflow skip only when that target surface is
|
||||
absent. Heal the trusted workflow before rerunning validation; do not port an
|
||||
unrelated runtime refactor or mutate the release candidate just to satisfy a
|
||||
newer `main`-only check.
|
||||
|
||||
```bash
|
||||
gh workflow run full-release-validation.yml \
|
||||
--repo openclaw/openclaw \
|
||||
@@ -106,7 +119,10 @@ Stop watchers before ending the turn or switching strategy.
|
||||
--jq '.jobs[] | select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled") | [.databaseId,.name,.conclusion,.url] | @tsv'
|
||||
```
|
||||
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
|
||||
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
|
||||
4. For secret-looking failures, validate a real completion from the same secret source before editing code. A successful model-list request is insufficient.
|
||||
Claude CLI subscription credentials are a separate native auth path; prove
|
||||
them in a clean-home CLI probe, never as a substitute for a required
|
||||
Anthropic API-key lane.
|
||||
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
|
||||
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release preflight helper that verifies required provider API keys can reach
|
||||
* their model-list endpoints without printing secret values.
|
||||
* Release preflight helper that verifies required provider API keys without
|
||||
* printing secret values. Anthropic must complete a prompt because model-list
|
||||
* access does not prove billing or inference entitlement.
|
||||
*/
|
||||
import process from "node:process";
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const arg = process.argv[index];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
if (!arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
const [key, inlineValue] = arg.slice(2).split("=", 2);
|
||||
const value = inlineValue ?? process.argv[index + 1];
|
||||
if (inlineValue === undefined) index += 1;
|
||||
if (inlineValue === undefined) {
|
||||
index += 1;
|
||||
}
|
||||
args.set(key, value);
|
||||
}
|
||||
|
||||
@@ -28,7 +33,9 @@ const timeoutMs = Number(args.get("timeout-ms") ?? 10_000);
|
||||
function envFirst(names) {
|
||||
for (const name of names) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return { name, value };
|
||||
if (value) {
|
||||
return { name, value };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -44,13 +51,19 @@ async function checkProvider(id, config) {
|
||||
try {
|
||||
const headers = config.headers(secret.value);
|
||||
const response = await fetch(config.url, {
|
||||
body: config.body,
|
||||
headers,
|
||||
method: config.method,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseBody = config.validateResponse
|
||||
? await response.json().catch(() => undefined)
|
||||
: undefined;
|
||||
const ok = response.ok && (!config.validateResponse || config.validateResponse(responseBody));
|
||||
return {
|
||||
id,
|
||||
ok: response.ok,
|
||||
status: response.ok ? "ok" : `http_${response.status}`,
|
||||
ok,
|
||||
status: response.ok ? (ok ? "ok" : "invalid_response") : `http_${response.status}`,
|
||||
env: secret.name,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -73,11 +86,21 @@ const providers = {
|
||||
},
|
||||
anthropic: {
|
||||
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
url: "https://api.anthropic.com/v1/messages",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
max_tokens: 8,
|
||||
messages: [{ role: "user", content: "Reply with OK." }],
|
||||
model: "claude-haiku-4-5",
|
||||
}),
|
||||
headers: (token) => ({
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
"x-api-key": token,
|
||||
}),
|
||||
validateResponse: (body) =>
|
||||
Array.isArray(body?.content) &&
|
||||
body.content.some((part) => typeof part?.text === "string" && part.text.trim()),
|
||||
},
|
||||
fireworks: {
|
||||
env: ["FIREWORKS_API_KEY"],
|
||||
@@ -108,7 +131,9 @@ let failed = false;
|
||||
for (const result of results) {
|
||||
const requiredLabel = required.has(result.id) ? "required" : "optional";
|
||||
console.log(`${result.id}: ${result.status} env=${result.env} ${requiredLabel}`);
|
||||
if (required.has(result.id) && !result.ok) failed = true;
|
||||
if (required.has(result.id) && !result.ok) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
|
||||
@@ -321,6 +321,7 @@ Upgrade with the beta channel.
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
pnpm release:fast-pretag-check
|
||||
pnpm check:architecture
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
@@ -329,6 +330,21 @@ pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
|
||||
publishable plugin must have a non-empty package-root `README.md`, build its
|
||||
package-local runtime, and pass the npm and ClawHub release metadata checks
|
||||
before a tag or publish workflow can start. Do not defer README, entrypoint,
|
||||
or packed-artifact failures to postpublish verification.
|
||||
- Before tagging, require green CI for the exact release-candidate SHA, not an
|
||||
earlier branch SHA. Heal every related red CI, release-check, packaging, or
|
||||
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
|
||||
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
|
||||
npm preflight passed.
|
||||
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
|
||||
release `install-smoke` group or equivalent root Dockerfile build for the
|
||||
exact candidate SHA and require it to pass. The tag-triggered Docker Release
|
||||
workflow is post-tag publishing, not the first valid proof that the root
|
||||
Dockerfile can build.
|
||||
- Before tagging, diff publishable plugin package manifests against the last
|
||||
reachable stable/beta release tag. For every newly publishable package
|
||||
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
|
||||
@@ -536,6 +552,16 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- `preflight_only=true` on the npm workflow is also the right way to validate an
|
||||
existing tag after publish; it should keep running the build checks even when
|
||||
the npm version is already published.
|
||||
- npm registry metadata is eventually consistent immediately after trusted
|
||||
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
|
||||
retries, and carry that verified tarball/integrity metadata into later proof
|
||||
steps instead of reading the registry again. If the OpenClaw npm child
|
||||
succeeded but the parent publish workflow failed on an immediate exact-version
|
||||
`E404`, verify the exact version with a cache-bypassed registry read, run the
|
||||
standalone postpublish verifier and the full beta verifier with the original
|
||||
successful child run IDs, then finalize the draft, dependency evidence asset,
|
||||
and release proof manually. Never rerun the publish workflow for that
|
||||
already-published version.
|
||||
- npm validation-only preflight may still be dispatched from ordinary branches
|
||||
when testing workflow changes before merge. Release checks and real publish
|
||||
use only `main` or `release/YYYY.M.PATCH`.
|
||||
@@ -644,9 +670,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
off, live OpenAI off, and regression failure off. Let it run in parallel
|
||||
with preflight and validation work.
|
||||
10. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
|
||||
to be green before tagging. Keep the remaining expensive Docker, Parallels,
|
||||
and published-package install/update lanes for after the beta is live unless
|
||||
the operator asks to run them before beta publication.
|
||||
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
release blocker specifically requires it. For stable releases, include the
|
||||
mac app, signing, notarization, and appcast path.
|
||||
@@ -703,8 +730,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
|
||||
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
|
||||
and exits red; do not undraft until the gap is repaired. The standalone
|
||||
verifier command remains the recovery probe:
|
||||
verifier command remains the first recovery probe:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
For a failed postpublish parent after successful publish children, also run
|
||||
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
|
||||
with the original child run IDs and an evidence output path before manually
|
||||
recreating the workflow's draft, dependency evidence asset, proof section,
|
||||
and publish step.
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
important low-risk fixes before starting expensive lanes, or increment to
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
4
.github/workflows/ci-check-arm-testbox.yml
vendored
4
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
4
.github/workflows/ci-check-testbox.yml
vendored
4
.github/workflows/ci-check-testbox.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -499,7 +499,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -564,7 +564,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -810,7 +810,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -850,10 +850,10 @@ jobs:
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
bun-launcher)
|
||||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
||||
@@ -899,7 +899,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -979,7 +979,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1056,7 +1056,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1131,7 +1131,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1258,7 +1258,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1288,6 +1288,7 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_LOCAL_CHECK: "0"
|
||||
TASK: ${{ matrix.task }}
|
||||
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1297,6 +1298,10 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
|
||||
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
|
||||
fi
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1360,6 +1365,8 @@ jobs:
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1392,7 +1399,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1515,6 +1522,15 @@ jobs:
|
||||
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
|
||||
echo "[skip] session transcript reader boundary check is not present in this checkout"
|
||||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
|
||||
echo "[skip] session transcript reader boundary script is not present in package.json"
|
||||
else
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
fi
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
;;
|
||||
@@ -1568,7 +1584,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1614,7 +1630,7 @@ jobs:
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
@@ -1661,7 +1677,7 @@ jobs:
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -2067,7 +2083,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
107
.github/workflows/full-release-validation.yml
vendored
107
.github/workflows/full-release-validation.yml
vendored
@@ -275,7 +275,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local dispatch_output run_id status conclusion url poll_count
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -298,8 +298,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -309,20 +307,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -423,7 +408,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local dispatch_output run_id status conclusion url poll_count
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -446,8 +431,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -457,20 +440,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -581,7 +551,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count run_json
|
||||
local dispatch_output run_id status conclusion url poll_count run_json
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
@@ -604,8 +574,6 @@ jobs:
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
@@ -615,20 +583,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -928,8 +883,6 @@ jobs:
|
||||
return "$status"
|
||||
}
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
|
||||
if [[ -z "${PACKAGE_SPEC// }" ]]; then
|
||||
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
|
||||
@@ -946,22 +899,16 @@ jobs:
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
|
||||
echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1073,31 +1020,23 @@ jobs:
|
||||
echo "- Release impact: advisory"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
gh_with_retry workflow run openclaw-performance.yml \
|
||||
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
|
||||
--ref "$CHILD_WORKFLOW_REF" \
|
||||
-f target_ref="$TARGET_SHA" \
|
||||
-f profile=release \
|
||||
-f repeat=3 \
|
||||
-f deep_profile=false \
|
||||
-f live_openai_candidate=false \
|
||||
-f fail_on_regression=false
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
-f fail_on_regression=false)"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
|
||||
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
6
.github/workflows/install-smoke.yml
vendored
6
.github/workflows/install-smoke.yml
vendored
@@ -476,19 +476,21 @@ jobs:
|
||||
- name: Run Rocky Linux installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
|
||||
|
||||
- name: Run Rocky Linux CLI installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
|
||||
|
||||
bun_global_install_smoke:
|
||||
|
||||
447
.github/workflows/ios-periphery-comment.yml
vendored
Normal file
447
.github/workflows/ios-periphery-comment.yml
vendored
Normal file
@@ -0,0 +1,447 @@
|
||||
name: iOS Periphery Dead Code Comment
|
||||
|
||||
on:
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
|
||||
workflows: ["iOS Periphery Dead Code"]
|
||||
types: [completed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment on PR
|
||||
runs-on: ubuntu-24.04
|
||||
if: >
|
||||
github.repository == 'openclaw/openclaw' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.name == 'iOS Periphery Dead Code'
|
||||
steps:
|
||||
- name: Upsert Periphery PR comment
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
|
||||
const run = context.payload.workflow_run;
|
||||
const pr = run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
core.info("No pull request attached to workflow_run.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const repository = `${owner}/${repo}`;
|
||||
if (run.repository?.full_name !== repository) {
|
||||
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (run.event !== "pull_request") {
|
||||
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
|
||||
return;
|
||||
}
|
||||
if (run.name !== "iOS Periphery Dead Code") {
|
||||
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const livePull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (livePull.data.state !== "open") {
|
||||
core.info(`Skipping closed PR #${pr.number}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.base?.repo?.full_name !== repository) {
|
||||
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.head?.sha !== run.head_sha) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
|
||||
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
|
||||
const scanSkipped =
|
||||
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
|
||||
if (scanSkipped) {
|
||||
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
|
||||
}
|
||||
|
||||
const artifacts = scanSkipped
|
||||
? []
|
||||
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const readReport = async () => {
|
||||
if (scanSkipped) {
|
||||
return;
|
||||
}
|
||||
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
|
||||
const artifact = artifacts.find((item) => item.name === artifactName);
|
||||
if (!artifact) {
|
||||
core.warning(`No ${artifactName} artifact found.`);
|
||||
return;
|
||||
}
|
||||
if (artifact.expired) {
|
||||
core.warning(`${artifactName} artifact expired.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxArchiveBytes = 1024 * 1024;
|
||||
const archiveSize = Number(artifact.size_in_bytes);
|
||||
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
|
||||
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const archive = await github.rest.actions.downloadArtifact({
|
||||
owner,
|
||||
repo,
|
||||
artifact_id: artifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
|
||||
const archivePath = path.join(dir, "artifact.zip");
|
||||
const archiveBuffer = Buffer.from(archive.data);
|
||||
fs.writeFileSync(archivePath, archiveBuffer);
|
||||
|
||||
const allowedArtifactFiles = new Set([
|
||||
"periphery.json",
|
||||
"periphery.status",
|
||||
"periphery.stderr.log",
|
||||
"periphery.stdout.json",
|
||||
"should-fail.txt",
|
||||
]);
|
||||
const maxEntries = allowedArtifactFiles.size;
|
||||
const maxEntryBytes = 2 * 1024 * 1024;
|
||||
const maxTotalBytes = 4 * 1024 * 1024;
|
||||
|
||||
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
|
||||
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
|
||||
const findEndOfCentralDirectoryOffset = () => {
|
||||
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
|
||||
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
|
||||
if (readUInt32(offset) === 0x06054b50) {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
|
||||
if (endOfCentralDirectoryOffset < 0) {
|
||||
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
|
||||
return;
|
||||
}
|
||||
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
|
||||
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
|
||||
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
|
||||
if (entryCount < 1 || entryCount > maxEntries) {
|
||||
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
|
||||
readUInt32(centralDirectoryOffset) !== 0x02014b50
|
||||
) {
|
||||
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = new Map();
|
||||
let totalUncompressedSize = 0;
|
||||
let offset = centralDirectoryOffset;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
|
||||
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compressionMethod = readUInt16(offset + 10);
|
||||
const generalPurposeBitFlag = readUInt16(offset + 8);
|
||||
const compressedSize = readUInt32(offset + 20);
|
||||
const uncompressedSize = readUInt32(offset + 24);
|
||||
const fileNameLength = readUInt16(offset + 28);
|
||||
const extraLength = readUInt16(offset + 30);
|
||||
const commentLength = readUInt16(offset + 32);
|
||||
const externalAttributes = readUInt32(offset + 38);
|
||||
const nameStart = offset + 46;
|
||||
const nameEnd = nameStart + fileNameLength;
|
||||
const nextOffset = nameEnd + extraLength + commentLength;
|
||||
if (nextOffset > archiveBuffer.length) {
|
||||
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
|
||||
const mode = externalAttributes >>> 16;
|
||||
const fileType = mode & 0o170000;
|
||||
const isRegularFile = fileType === 0 || fileType === 0o100000;
|
||||
const invalidName =
|
||||
!allowedArtifactFiles.has(name) ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\") ||
|
||||
name.includes("..") ||
|
||||
path.isAbsolute(name);
|
||||
if (invalidName) {
|
||||
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (!isRegularFile || name.endsWith("/")) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
|
||||
return;
|
||||
}
|
||||
if (entries.has(name)) {
|
||||
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (![0, 8].includes(compressionMethod)) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
|
||||
return;
|
||||
}
|
||||
if ((generalPurposeBitFlag & 0x1) !== 0) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
|
||||
return;
|
||||
}
|
||||
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
totalUncompressedSize += uncompressedSize;
|
||||
if (totalUncompressedSize > maxTotalBytes) {
|
||||
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.set(name, { uncompressedSize });
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const files = new Map();
|
||||
for (const [name, entry] of entries) {
|
||||
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
|
||||
timeout: 5000,
|
||||
});
|
||||
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
|
||||
return;
|
||||
}
|
||||
files.set(name, contents);
|
||||
}
|
||||
|
||||
const read = (name) => {
|
||||
return files.get(name) ?? "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
const validFindings =
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every(
|
||||
(finding) =>
|
||||
finding !== null &&
|
||||
typeof finding === "object" &&
|
||||
!Array.isArray(finding),
|
||||
);
|
||||
if (validFindings) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { findings, status };
|
||||
};
|
||||
const report = await readReport();
|
||||
const status = report?.status ?? 1;
|
||||
const findings = report?.findings ?? null;
|
||||
|
||||
const sanitizeCell = (value) => {
|
||||
const normalized = String(value ?? "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
const maxEncodedLength = 180;
|
||||
let escaped = "";
|
||||
for (const character of normalized) {
|
||||
const encoded =
|
||||
character === "`"
|
||||
? "'"
|
||||
: character === "|"
|
||||
? "\\|"
|
||||
: character;
|
||||
if (escaped.length + encoded.length > maxEncodedLength) {
|
||||
break;
|
||||
}
|
||||
escaped += encoded;
|
||||
}
|
||||
return `\`${escaped || "-"}\``;
|
||||
};
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
return {
|
||||
file: file ? `apps/ios/${file}` : "",
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
let mode = "failure";
|
||||
let body = `${marker}\n`;
|
||||
if (scanSkipped) {
|
||||
mode = "skipped";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
|
||||
].join("\n");
|
||||
} else if (findings === null) {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
mode = "success";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
const shown = rows.slice(0, 50);
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
|
||||
"",
|
||||
"| File | Line | Kind | Name |",
|
||||
"| --- | ---: | --- | --- |",
|
||||
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
|
||||
rows.length > shown.length ? "" : null,
|
||||
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
} else {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
body += "\n";
|
||||
const maxCommentChars = 60_000;
|
||||
if (body.length > maxCommentChars) {
|
||||
body = [
|
||||
marker,
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
if (!existing && ["skipped", "success"].includes(mode)) {
|
||||
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (
|
||||
currentPull.data.state !== "open" ||
|
||||
currentPull.data.base?.repo?.full_name !== repository ||
|
||||
currentPull.data.head?.sha !== run.head_sha
|
||||
) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: run.workflow_id,
|
||||
event: "pull_request",
|
||||
head_sha: run.head_sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const supersedingRun = workflowRuns.find(
|
||||
(candidate) =>
|
||||
(candidate.id === run.id ||
|
||||
candidate.pull_requests?.some(
|
||||
(candidatePull) => candidatePull.number === pr.number,
|
||||
)) &&
|
||||
(candidate.run_number > run.run_number ||
|
||||
(candidate.run_number === run.run_number &&
|
||||
candidate.run_attempt > run.run_attempt)),
|
||||
);
|
||||
if (supersedingRun) {
|
||||
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
body,
|
||||
});
|
||||
229
.github/workflows/ios-periphery.yml
vendored
Normal file
229
.github/workflows/ios-periphery.yml
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
name: iOS Periphery Dead Code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
name: Detect iOS scan scope
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should-scan: ${{ steps.scope.outputs.should-scan }}
|
||||
steps:
|
||||
- name: Detect changed paths
|
||||
id: scope
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
core.setOutput("should-scan", "true");
|
||||
return;
|
||||
}
|
||||
if (context.payload.pull_request?.draft) {
|
||||
core.setOutput("should-scan", "false");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const isScanPath = (filename) =>
|
||||
typeof filename === "string" && (
|
||||
filename.startsWith("apps/ios/") ||
|
||||
filename === ".github/workflows/ios-periphery.yml" ||
|
||||
filename === ".github/workflows/ios-periphery-comment.yml" ||
|
||||
filename === "config/swiftformat" ||
|
||||
filename === "config/swiftlint.yml"
|
||||
);
|
||||
const shouldScan = files.some(
|
||||
({ filename, previous_filename: previousFilename }) =>
|
||||
isScanPath(filename) || isScanPath(previousFilename)
|
||||
);
|
||||
core.setOutput("should-scan", String(shouldScan));
|
||||
|
||||
scan:
|
||||
name: Scan iOS dead code
|
||||
needs: scope
|
||||
if: ${{ needs.scope.outputs.should-scan == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Verify Xcode
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftformat swiftlint periphery
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/ios-configure-signing.sh
|
||||
./scripts/ios-write-version-xcconfig.sh
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: Run Periphery
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output_dir="$RUNNER_TEMP/ios-periphery"
|
||||
mkdir -p "$output_dir"
|
||||
cd apps/ios
|
||||
set +e
|
||||
periphery scan \
|
||||
--config .periphery.yml \
|
||||
--strict \
|
||||
--format json \
|
||||
--write-results "$output_dir/periphery.json" \
|
||||
>"$output_dir/periphery.stdout.json" \
|
||||
2>"$output_dir/periphery.stderr.log"
|
||||
periphery_status="$?"
|
||||
set -e
|
||||
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
|
||||
if [ ! -s "$output_dir/periphery.json" ]; then
|
||||
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
|
||||
fi
|
||||
|
||||
- name: Build Periphery report
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery");
|
||||
const read = (name) => {
|
||||
const file = path.join(outputDir, name);
|
||||
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
if (Array.isArray(parsed)) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const escapeCommandData = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("%", "%25")
|
||||
.replaceAll("\r", "%0D")
|
||||
.replaceAll("\n", "%0A");
|
||||
const escapeCommandProperty = (value) =>
|
||||
escapeCommandData(value)
|
||||
.replaceAll(":", "%3A")
|
||||
.replaceAll(",", "%2C");
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
const repoFile = file ? `apps/ios/${file}` : "";
|
||||
return {
|
||||
file: repoFile,
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.file) continue;
|
||||
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
|
||||
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
|
||||
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
|
||||
}
|
||||
|
||||
let shouldFail = "1";
|
||||
let summary = "";
|
||||
|
||||
if (findings === null) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
shouldFail = "0";
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
|
||||
].join("\n");
|
||||
} else {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload Periphery report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"
|
||||
@@ -2222,7 +2222,11 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2447,7 +2451,11 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
|
||||
20
.github/workflows/openclaw-performance.yml
vendored
20
.github/workflows/openclaw-performance.yml
vendored
@@ -56,6 +56,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
|
||||
@@ -187,11 +188,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
KOVA_SRC="${RUNNER_TEMP}/kova-src"
|
||||
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
|
||||
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
|
||||
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
|
||||
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" checkout "$KOVA_REF"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
|
||||
|
||||
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
|
||||
-o "$ocm_archive" \
|
||||
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
|
||||
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
|
||||
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
|
||||
|
||||
git init -b main "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
|
||||
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
|
||||
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
|
||||
cat > "$HOME/.local/bin/kova" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
export KOVA_HOME="${KOVA_HOME}"
|
||||
|
||||
@@ -1112,13 +1112,14 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
|
||||
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
|
||||
31
.github/workflows/windows-blacksmith-testbox.yml
vendored
31
.github/workflows/windows-blacksmith-testbox.yml
vendored
@@ -65,7 +65,9 @@ jobs:
|
||||
fi
|
||||
runner_ssh_port="${BLACKSMITH_SSH_PORT:-22}"
|
||||
|
||||
response="$(curl -s -f -L --post302 --post303 -X POST "${api_url}/api/testbox/phone-home" \
|
||||
hydrating_response="$RUNNER_TEMP/testbox-hydrating.response"
|
||||
hydrating_http_code="$(curl -sS -L --post302 --post303 -o "$hydrating_response" -w '%{http_code}' \
|
||||
-X POST "${api_url}/api/testbox/phone-home" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${auth_token}" \
|
||||
-d "{
|
||||
@@ -77,7 +79,15 @@ jobs:
|
||||
\"working_directory\": \"${GITHUB_WORKSPACE}\",
|
||||
\"adopted_run_id\": \"${GITHUB_RUN_ID}\",
|
||||
\"metadata\": {}
|
||||
}" 2>/dev/null || true)"
|
||||
}" || true)"
|
||||
|
||||
echo "phone_home_hydrating_http=${hydrating_http_code}"
|
||||
if [[ ! "$hydrating_http_code" =~ ^2 ]]; then
|
||||
echo "Blacksmith phone-home hydrating failed; response body:" >&2
|
||||
cat "$hydrating_response" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
response="$(cat "$hydrating_response")"
|
||||
|
||||
echo "$TESTBOX_ID" > "$state/testbox_id"
|
||||
echo "$installation_model_id" > "$state/installation_model_id"
|
||||
@@ -100,12 +110,14 @@ jobs:
|
||||
fi
|
||||
|
||||
ssh_public_key="$(cat "$state/ssh_public_key" 2>/dev/null || true)"
|
||||
if [ -n "$ssh_public_key" ]; then
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
if [ -z "$ssh_public_key" ]; then
|
||||
echo "Blacksmith phone-home did not return an SSH public key; testbox cannot accept CLI connections." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$ssh_public_key" >> ~/.ssh/authorized_keys
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -161,6 +173,11 @@ jobs:
|
||||
-H "Authorization: Bearer ${auth_token}" \
|
||||
--data-binary @"$ready_body" || true)"
|
||||
echo "phone_home_ready_http=${http_code}"
|
||||
if [[ ! "$http_code" =~ ^2 ]]; then
|
||||
echo "Blacksmith phone-home ready failed; response body:" >&2
|
||||
cat "$RUNNER_TEMP/testbox-ready.response" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo "Testbox ready!"
|
||||
|
||||
14
.github/workflows/windows-testbox-probe.yml
vendored
14
.github/workflows/windows-testbox-probe.yml
vendored
@@ -133,8 +133,9 @@ jobs:
|
||||
$rootfs = "C:\wsl\ubuntu-noble-wsl.rootfs.tar.gz"
|
||||
New-Item -ItemType Directory -Force -Path @((Split-Path -Parent $rootfs), $wslRoot) | Out-Null
|
||||
Invoke-WebRequest -Uri $env:UBUNTU_WSL_ROOTFS_URL -OutFile $rootfs -UseBasicParsing
|
||||
wsl.exe --import UbuntuProbe $wslRoot $rootfs --version 2
|
||||
Write-Host "wsl_import_exit=$LASTEXITCODE"
|
||||
$import = Invoke-WslText -Arguments @("--import", "UbuntuProbe", $wslRoot, $rootfs, "--version", "2")
|
||||
Write-Host $import.Text
|
||||
Write-Host "wsl_import_exit=$($import.Code)"
|
||||
$list = Invoke-WslText -Arguments @("--list", "--verbose")
|
||||
Write-Host $list.Text
|
||||
Write-Host "wsl_list_after_import_exit=$($list.Code)"
|
||||
@@ -144,14 +145,15 @@ jobs:
|
||||
if ($distros.Count -gt 0) {
|
||||
$distro = $distros[0]
|
||||
Write-Host "wsl_probe_distro=$distro"
|
||||
wsl.exe -d $distro --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
|
||||
$exec = Invoke-WslText -Arguments @("-d", $distro, "--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
|
||||
} else {
|
||||
wsl.exe --exec bash -lc 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi'
|
||||
$exec = Invoke-WslText -Arguments @("--exec", "bash", "-lc", 'set -euo pipefail; uname -a; if [ -f /etc/os-release ]; then sed -n "1,8p" /etc/os-release; fi')
|
||||
}
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host $exec.Text
|
||||
if ($exec.Code -eq 0) {
|
||||
$ok = $true
|
||||
}
|
||||
Write-Host "wsl_exec_exit=$LASTEXITCODE"
|
||||
Write-Host "wsl_exec_exit=$($exec.Code)"
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
|
||||
3
.github/workflows/workflow-sanity.yml
vendored
3
.github/workflows/workflow-sanity.yml
vendored
@@ -251,3 +251,6 @@ jobs:
|
||||
|
||||
- name: Check plugin SDK API baseline drift
|
||||
run: pnpm plugin-sdk:api:check
|
||||
|
||||
- name: Check plugin SDK surface budget
|
||||
run: pnpm plugin-sdk:surface:check
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -83,6 +83,12 @@ apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
apps/android/fastlane/report.xml
|
||||
apps/android/fastlane/Preview.html
|
||||
apps/android/fastlane/test_output/
|
||||
apps/android/fastlane/logs/
|
||||
apps/android/fastlane/.env
|
||||
apps/android/fastlane/metadata/android/**/images/
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@@ -127,6 +133,8 @@ mantis/
|
||||
!.agents/skills/clawdtributor/**
|
||||
!.agents/skills/control-ui-e2e/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/discord-user-post/
|
||||
!.agents/skills/discord-user-post/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/technical-documentation/
|
||||
|
||||
@@ -172,7 +172,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
@@ -214,6 +214,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults.
|
||||
- Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs.
|
||||
- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios/<theme>/*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`.
|
||||
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
|
||||
- Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`.
|
||||
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
- Channel delivery is steadier across WhatsApp GIF/media placeholders, Telegram rich progress and media directives, QQBot cron voice sends, Feishu card replies, Discord ingress/search, Matrix mentions, Mattermost mentions, and generated reply preservation. (#93679, #93698, #93690, #92947, #93618, #93449, #93407, #88796, #83156, #93242, #93629) Thanks @liuhao1024, @obviyus, @ZengWen-DT, @mgunnin, @SebTardif, @wdx-agent-io, @iloveleon19, and @lzyyzznl.
|
||||
- Agent, Gateway, and session recovery paths now surface Codex app-server failures, route approval notices with write scope, bootstrap plugin session targets, preserve prompt-released and failed-turn history, recover stale transcripts on reset, and keep assistant string content readable. (#93665, #93656, #93630, #93646, #89483, #93194, #93496) Thanks @litang9, @mushuiyu886, @ZengWen-DT, @Alix-007, @IWhatsskill, @snowzlm, and @harjothkhara.
|
||||
- Provider and plugin surfaces gained Codex hosted web search, remote-node Codex dynamic tools, Google Meet realtime provider secret inputs, Qwen image prompt placement, Bedrock embedding model normalization, key-free web-search opt-in behavior, and externally installed channel plugin startup loading. (#93446, #93654, #93677, #93649, #93452, #93616, #93470) Thanks @fuller-stack-dev, @goutamadwant, @LiuwqGit, @davemorin, and @sunlit-deng.
|
||||
- UI, onboarding, update, and setup flows preserve default models during auth setup, copy Control UI code blocks over plain HTTP, clear stale Talk errors, keep WebChat replies from double-rendering, preserve CJK IME composition, skip unsupported Homebrew prompts, and avoid per-Node npm prefixes during self-update. (#93658, #93666, #93606, #93298, #93498, #93521, #93650) Thanks @ml12580, @Pick-cat, @liuhao1024, @zhangguiping-xydt, and @Zhaoqj2016.
|
||||
|
||||
### Changes
|
||||
|
||||
- Add compact cron list responses and isolated model-usage diagnostics for scheduled runs. (#93395, #93398) Thanks @yu-xin-c and @849261680.
|
||||
- Add Codex hosted web search and expose remote-node execution as a Codex dynamic tool while keeping key-free web-search providers opt-in. (#93446, #93654, #93616) Thanks @fuller-stack-dev and @davemorin.
|
||||
- Add iOS watch action surfaces and refresh Android/iOS release upload metadata paths for mobile release preparation. (#93387) Thanks @Solvely-Colin and @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channels: preserve unsent text-only finals after streamed partial content, recover lone Telegram spool handlers on timeout, bind Telegram bot mentions to assistant identity, hydrate Telegram group reply-chain media, preserve Mattermost Codex progress previews, bound WhatsApp read-receipt stalls, and distinguish WhatsApp GIF playback placeholders. (#93629, #93615, #93088, #93575, #93476, #93303, #93679) Thanks @liuhao1024, @0xghost42, @kesslerio, @eldar702, @goutamadwant, and @Alix-007.
|
||||
- Gateway, agents, and CLI: surface Codex app-server failures, compute usage totals across all sessions, accept `--log-level` after subcommands, skip compile cache on early Node 24.x, honor embedded-run default models, and preserve aborted isolated-run failures. (#93665, #93612, #93455, #89799, #93439, #93471) Thanks @litang9, @liuhao1024, @ooiuuii, @zhangguiping-xydt, @harjothkhara, and @BhargavSatya.
|
||||
- Providers and plugins: keep Google Meet realtime secret inputs declared, place DashScope image prompts in user content, strip Bedrock inference profile prefixes for embeddings, resolve provider policy for plugin-owned CLI backends, and allow Dreaming sidecars through restrictive memory allowlists. (#93677, #93649, #93452, #93261, #93678) Thanks @goutamadwant, @LiuwqGit, and @BitmapAsset.
|
||||
- Skills, memory, and doctor: preserve ClawHub origin provenance on readback, clear corrupt skill idempotency pointers, report skipped QMD embedding probes, archive superseded plugin install index conflicts, and repair null `agents.list[].workspace` values. (#93314, #93509, #93473, #93648, #93105) Thanks @Alix-007, @TurboTheTurtle, and @xydigit-sj.
|
||||
- Security and policy: audit open-DM tool exposure, redact secrets in `/debug` output, avoid parent group allowlist false positives, and keep externally installed channel plugins loaded at Gateway startup. (#92883, #93333, #93434, #93470) Thanks @yu-xin-c, @Alix-007, @kingrubic, and @sunlit-deng.
|
||||
- Runtime and tooling: rewrite pnpm versioned entry paths to stable wrappers, summarize cleanup dry-runs by label, route text decoding through the shared Windows codepage fallback, and keep root-owned service commands out of stale sudo scope. (#93671, #93565, #93555, #93693) Thanks @liuhao1024, @AgentArcLab, and @zhanxingxin1998.
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
### Highlights
|
||||
@@ -23,13 +47,27 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow.
|
||||
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
|
||||
- Auto-reply/groups: keep ordinary group text replies on automatic final-reply delivery while allowing `message(action=send)` for files, images, and other attachments to the same group or topic. Carries forward #43276; refs #48004. Thanks @NayukiChiba and @ShakaRover.
|
||||
- Auto-reply/skills: preserve multiline payloads for `/skill` and direct skill slash commands while keeping command-head normalization for aliases, colon syntax, and bot mentions. Fixes #79155; carries forward #81305. Thanks @web3blind.
|
||||
- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
|
||||
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
|
||||
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.
|
||||
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
|
||||
- Agents/exec: default empty-success background completion notices on only for real chat channels, preserving explicit opt-outs and keeping generic providers silent while carrying forward the narrow UX intent from #39726 and #46926. Thanks @Sapientropic and @wenkang-xie.
|
||||
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
|
||||
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
|
||||
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
|
||||
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
|
||||
- TUI: reload the active session after external `/new` or `/reset` session-change events so stale transcript and stream state clear promptly. Fixes #38966; carries forward #40472. Thanks @yizhanzjz and @wsyjh8.
|
||||
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
|
||||
- Gateway CLI: tolerate a single transient clean WebSocket close before `hello-ok` so one-shot RPC calls reconnect instead of failing noisily, while repeated clean pre-hello closes still surface. Carries forward source PRs #54475 and #54774; #85253 covered adjacent connect assembly diagnostics. Thanks @ruanrrn.
|
||||
- Gateway/Linux: keep root-owned systemd user service lifecycle commands on root's user manager when a stale `SUDO_USER` remains in a root shell with root's user bus environment. Fixes #81410. Thanks @Ericksza and @ChuckClose-tech.
|
||||
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
|
||||
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
|
||||
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
|
||||
- Cron: preserve model, fallback, thinking, timeout, light-context, unsafe-content, and tool allow-list overrides on implicit text payloads by promoting them to agent turns, while explicit system events still prune those fields. Fixes #28905; carries forward #64060 and #73946. Thanks @liaoandi.
|
||||
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
|
||||
# installed prod graph in the same step that runs offline prune.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
|
||||
106
appcast.xml
106
appcast.xml
@@ -2,6 +2,48 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.8</title>
|
||||
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606000890</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.8</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.8</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, preserved intentional line breaks, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #93164, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.</li>
|
||||
<li>Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, auto-reply message-tool final replies, reset archive fallback reads, restart shutdown aborts, yielded subagent pauses, trusted subagent thinking override fallback, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #92879, #91357, #92631, #92412, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @masatohoshino, @CadanHu, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.</li>
|
||||
<li>Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, OAuth image-default routing through Codex, bounded model browse discovery, LM Studio binary thinking-off delivery, storeless OpenAI Responses replay gating, invalid OpenAI reasoning-signature and genericized Anthropic thinking-signature recovery, Claude 4.5 Copilot tool-streaming safety, and OpenAI/Anthropic-family payload quarantine for unreadable or post-hook tool schemas. (#92796, #90116, #92627, #91218, #90686, #92824, #92247, #92002, #90706, #92941, #92201, #92916, #75393, #92908, #92921, #92928) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @nxmxbbd, @bek91, @samson910022, @mmyzwl, @CarlCapital, @snowzlm, @Kailigithub, and @vincentkoc.</li>
|
||||
<li><code>/usage</code> and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
|
||||
<li>UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin.</li>
|
||||
<li>Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, full memory reindexes preserve rollback/cache recovery, raw Memory Wiki source pages stop looking malformed, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, @arlen8411, and @yhterrance.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle.</li>
|
||||
<li>Web search: keep key-free providers such as Parallel Free, DuckDuckGo, Ollama, and Codex Hosted Search as explicit opt-ins instead of selecting them automatically when no API-backed provider is configured. (#93616) Thanks @davemorin and @vincentkoc.</li>
|
||||
<li>Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including preserved intentional line breaks, rich prompt handoff to CLI backends, and transport fixtures for richer drafts. (#92679, #93164, #92513) Thanks @obviyus and @TurboTheTurtle.</li>
|
||||
<li>Agent commands: support <code>/btw</code> in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.</li>
|
||||
<li>Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored.</li>
|
||||
<li>Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Channels and delivery: preserve account-scoped DM channel send policy, intentional rich-message line breaks in Telegram and status output, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Feishu dynamic-agent routes after persisted binding reuse, Slack outbound <code>message_sent</code> hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #93164, #92679, #89421, #89943, #42837, #92814, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @liuhao1024, @lundog, @TurboTheTurtle, and @yhterrance.</li>
|
||||
<li>Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.</li>
|
||||
<li>Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, clamp trusted subagent thinking overrides through provider/model fallback, preserve yielded media completions, deliver channel message-tool final replies through auto-reply while hiding internal delivery hints, restore reset archive fallback reads when active async transcripts are missing, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions, slash-command block replies, and trajectory export commands in WebChat, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92412, #92146, #92879, #91287, #92468, #92510, #91246, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @masatohoshino, @CadanHu, @ZengWen-DT, @zhangguiping-xydt, and @TurboTheTurtle.</li>
|
||||
<li>Providers and model replay: preserve storeless OpenAI Responses replay compatibility, recover invalid OpenAI reasoning signatures and genericized Anthropic thinking-signature replay errors, route OAuth image defaults through Codex for eligible OpenAI profiles, avoid eager tool streaming for Claude 4.5 in Copilot, quarantine unreadable and post-hook OpenAI/Anthropic-family tool schemas without broadening allowed tool choices, deliver explicit thinking-off requests to LM Studio binary-thinking models, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #92941, #92201, #92916, #92824, #75393, #92908, #92921, #92928, #92002, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @mmyzwl, @CarlCapital, @bek91, @Kailigithub, @vincentkoc, @rohitjavvadi, @samson910022, @nxmxbbd, @liuhao1024, @bymle, and @mushuiyu886.</li>
|
||||
<li>Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, preserve full-reindex rollback/cache recovery, treat raw Memory Wiki source pages as source evidence, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752, #92881, #59137, #92876, #69700) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, @TSHOGX, and @arlen8411.</li>
|
||||
<li>UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved <code>/model</code> confirmation refs, stale foreground iOS Gateway reconnects, and paused setup-parent stdin after inherited-stdio child exit. (#90658, #92622, #91353, #92705, #92779, #92773, #92552, #93159) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, @Solvely-Colin, and @fuller-stack-dev.</li>
|
||||
<li>Plugins and updates: repair missing required platform packages during managed plugin installs and updates, including omitted Codex platform binaries.</li>
|
||||
<li>Dependencies: update Hono to 4.12.25 so published OpenClaw and ACPX packages use the patched runtime.</li>
|
||||
<li>Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, fold Telegram RTT sampling into live QA evidence, simplify QA scorecard mappings around canonical coverage IDs, keep QA Lab bootstrap selection assertions aligned with flow-only scenarios, skip QA coverage artifact consumers when runtime parity producer status is not green, keep Feishu lifecycle release checks pointed at the active fixture config, isolate trajectory-export live seed turns from Codex-native shell approvals, preserve release-check child refs while pinning expected SHAs, widen live OpenAI TTS budgets for slower provider responses, and avoid false downgrade prompts for unresolved latest-tag updates. (#92652, #92550, #92558, #92911) Thanks @RomneyDa and @Andy312432.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.8/OpenClaw-2026.6.8.zip" length="55815364" type="application/octet-stream" sparkle:edSignature="hLJ14xg6+DMFrXViIW3Njs++OPIGO+RWH9h+mPCSzXPAkKyYUGvtOLu1qEKvvfC8rs5FGgW/w4zDLfD2azqiBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.5</title>
|
||||
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
|
||||
@@ -209,69 +251,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.28</title>
|
||||
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052890</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
|
||||
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
|
||||
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
|
||||
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
|
||||
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
|
||||
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
|
||||
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
|
||||
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Status: show active subagent details in status output.</li>
|
||||
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
|
||||
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
|
||||
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
|
||||
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
|
||||
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
|
||||
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
|
||||
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
|
||||
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
|
||||
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
|
||||
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
|
||||
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
|
||||
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
|
||||
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
|
||||
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
|
||||
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
|
||||
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
|
||||
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
|
||||
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
|
||||
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
|
||||
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
|
||||
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
|
||||
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
|
||||
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
|
||||
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
|
||||
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
|
||||
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
|
||||
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
|
||||
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
|
||||
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
|
||||
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
|
||||
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
|
||||
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
6
apps/android/Config/Version.properties
Normal file
6
apps/android/Config/Version.properties
Normal file
@@ -0,0 +1,6 @@
|
||||
# Shared Android version defaults.
|
||||
# Source of truth: apps/android/version.json
|
||||
# Generated by scripts/android-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060201
|
||||
@@ -32,7 +32,7 @@ cd apps/android
|
||||
./gradlew :app:installPlayDebug
|
||||
./gradlew :app:testPlayDebugUnitTest
|
||||
cd ../..
|
||||
bun run android:bundle:release
|
||||
pnpm android:release:archive
|
||||
```
|
||||
|
||||
Third-party debug flavor:
|
||||
@@ -44,10 +44,29 @@ cd apps/android
|
||||
./gradlew :app:testThirdPartyDebugUnitTest
|
||||
```
|
||||
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
|
||||
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
|
||||
|
||||
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
|
||||
```bash
|
||||
pnpm android:version
|
||||
pnpm android:version:check
|
||||
pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
```
|
||||
|
||||
Generate raw Google Play screenshots:
|
||||
|
||||
```bash
|
||||
pnpm android:screenshots
|
||||
```
|
||||
|
||||
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
|
||||
|
||||
- Play build: `openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `openclaw-<version>-third-party-release.apk`
|
||||
|
||||
`pnpm android:bundle:release` is an alias for the same archive helper.
|
||||
|
||||
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
|
||||
|
||||
Flavor-specific direct Gradle tasks:
|
||||
|
||||
|
||||
38
apps/android/VERSIONING.md
Normal file
38
apps/android/VERSIONING.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# OpenClaw Android Versioning
|
||||
|
||||
Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`.
|
||||
|
||||
## Version model
|
||||
|
||||
- `apps/android/version.json` is the source of truth.
|
||||
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
|
||||
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
|
||||
|
||||
Examples:
|
||||
|
||||
- `version = 2026.6.2`
|
||||
- `versionCode = 2026060201`
|
||||
- another upload on the same release train: `versionCode = 2026060202`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm android:version
|
||||
pnpm android:version:check
|
||||
pnpm android:version:sync
|
||||
pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
```
|
||||
|
||||
## Release Workflow
|
||||
|
||||
1. Pin Android to the intended release version.
|
||||
2. Run `pnpm android:version:sync`.
|
||||
3. Update `apps/android/fastlane/metadata/android/en-US/release_notes.txt`.
|
||||
4. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
|
||||
5. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
|
||||
6. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
|
||||
7. Promote to production manually in Google Play Console.
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
@@ -1,6 +1,24 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import java.util.Properties
|
||||
|
||||
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
||||
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
|
||||
val openClawAndroidVersionProperties =
|
||||
Properties().apply {
|
||||
if (!openClawAndroidVersionFile.isFile) {
|
||||
error("Missing Android version properties. Run `pnpm android:version:sync`.")
|
||||
}
|
||||
openClawAndroidVersionFile.inputStream().use(::load)
|
||||
}
|
||||
|
||||
fun requireOpenClawAndroidVersionProperty(name: String): String =
|
||||
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
|
||||
|
||||
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
|
||||
val openClawAndroidVersionCode =
|
||||
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
|
||||
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
@@ -65,8 +83,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026060201
|
||||
versionName = "2026.6.2"
|
||||
versionCode = openClawAndroidVersionCode
|
||||
versionName = openClawAndroidVersionName
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
|
||||
const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
|
||||
|
||||
enum class AndroidScreenshotScene(
|
||||
val rawValue: String,
|
||||
) {
|
||||
Connect("connect"),
|
||||
Chat("chat"),
|
||||
Voice("voice"),
|
||||
Screen("screen"),
|
||||
Settings("settings"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Connect
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? {
|
||||
if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) {
|
||||
return null
|
||||
}
|
||||
return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.ui.AndroidScreenshotModeScreen
|
||||
import ai.openclaw.app.ui.OpenClawTheme
|
||||
import ai.openclaw.app.ui.RootScreen
|
||||
import android.content.Intent
|
||||
@@ -51,6 +52,12 @@ class MainActivity : ComponentActivity() {
|
||||
pendingIntent = intent
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
if (BuildConfig.DEBUG) {
|
||||
parseAndroidScreenshotModeIntent(intent)?.let { scene ->
|
||||
enterScreenshotMode(scene)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
|
||||
@@ -79,6 +86,12 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun enterScreenshotMode(scene: AndroidScreenshotScene) {
|
||||
setContent {
|
||||
AndroidScreenshotModeScreen(scene = scene)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
foreground = true
|
||||
|
||||
@@ -443,6 +443,7 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -485,6 +486,14 @@ class NodeRuntime(
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun subscribeOperatorSessionEvents() {
|
||||
try {
|
||||
operatorSession.request("sessions.subscribe", null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
|
||||
@@ -311,7 +311,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -321,7 +320,6 @@ class ChatController(
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
@@ -332,6 +330,17 @@ class ChatController(
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"sessions.changed" -> {
|
||||
if (payloadJson.isNullOrBlank()) {
|
||||
refreshSessionsForCurrentWindow()
|
||||
} else {
|
||||
handleSessionsChangedEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
"session.message" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleSessionMessageEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
@@ -353,6 +362,7 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -388,6 +398,10 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSessionsForCurrentWindow() {
|
||||
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
@@ -457,6 +471,7 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -472,6 +487,31 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionsChangedEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
if (payload["reason"].asStringOrNull() == "delete") {
|
||||
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
|
||||
return
|
||||
}
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
} else {
|
||||
refreshSessionsForCurrentWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionMessageEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
@@ -600,6 +640,7 @@ class ChatController(
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
@@ -622,20 +663,69 @@ class ChatController(
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
sessionInfo = sessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
|
||||
}
|
||||
|
||||
private fun parseSessionEntry(
|
||||
obj: JsonObject?,
|
||||
fallbackKey: String? = null,
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
updatedAtMs = obj["updatedAt"].asLongOrNull(),
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim(),
|
||||
totalTokens = obj["totalTokens"].asLongOrNull(),
|
||||
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
|
||||
contextTokens = obj["contextTokens"].asLongOrNull(),
|
||||
hasContextUsageMetadata =
|
||||
"totalTokens" in obj ||
|
||||
"totalTokensFresh" in obj ||
|
||||
"contextTokens" in obj,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateSessionFromHistory(history: ChatHistory) {
|
||||
val info = history.sessionInfo ?: return
|
||||
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
|
||||
}
|
||||
|
||||
private fun upsertSessionEntry(
|
||||
entry: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
) {
|
||||
val current = _sessions.value
|
||||
val index = current.indexOfFirst { it.key == entry.key }
|
||||
_sessions.value =
|
||||
if (index >= 0) {
|
||||
current.toMutableList().also {
|
||||
it[index] =
|
||||
mergeChatSessionEntry(
|
||||
existing = it[index],
|
||||
next = entry,
|
||||
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
listOf(entry) + current
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSessionEntry(sessionKey: String?) {
|
||||
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
@@ -857,3 +947,44 @@ private fun JsonElement?.asLongOrNull(): Long? =
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toBooleanStrictOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun mergeChatSessionEntry(
|
||||
existing: ChatSessionEntry,
|
||||
next: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
): ChatSessionEntry {
|
||||
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
|
||||
return existing.copy(
|
||||
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
|
||||
displayName = next.displayName ?: existing.displayName,
|
||||
totalTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokens
|
||||
next.hasContextUsageMetadata -> next.totalTokens
|
||||
else -> null
|
||||
},
|
||||
totalTokensFresh =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokensFresh
|
||||
next.hasContextUsageMetadata -> next.totalTokensFresh
|
||||
else -> null
|
||||
},
|
||||
contextTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
|
||||
next.hasContextUsageMetadata -> next.contextTokens
|
||||
else -> null
|
||||
},
|
||||
hasContextUsageMetadata =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
|
||||
else -> next.hasContextUsageMetadata
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
val totalTokens: Long? = null,
|
||||
val totalTokensFresh: Boolean? = null,
|
||||
val contextTokens: Long? = null,
|
||||
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,7 @@ data class ChatHistory(
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
val sessionInfo: ChatSessionEntry? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AndroidScreenshotScene
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.WifiTethering
|
||||
import androidx.compose.material3.Icon
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AndroidScreenshotModeScreen(scene: AndroidScreenshotScene) {
|
||||
ClawDesignTheme(dark = true) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.padding(horizontal = 20.dp, vertical = 26.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ScreenshotHeader(scene)
|
||||
ScreenshotSceneBody(scene = scene, modifier = Modifier.weight(1f))
|
||||
ScreenshotTabBar(activeScene = scene)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenshotHeader(scene: AndroidScreenshotScene) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(text = "OpenClaw", style = ClawTheme.type.title, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = sceneTitle(scene),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
StatusPill(label = "Connected", color = ClawTheme.colors.success)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenshotSceneBody(
|
||||
scene: AndroidScreenshotScene,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
when (scene) {
|
||||
AndroidScreenshotScene.Connect -> ConnectScene()
|
||||
AndroidScreenshotScene.Chat -> ChatScene()
|
||||
AndroidScreenshotScene.Voice -> VoiceScene()
|
||||
AndroidScreenshotScene.Screen -> ScreenScene()
|
||||
AndroidScreenshotScene.Settings -> SettingsScene()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectScene() {
|
||||
FeaturePanel(icon = Icons.Default.WifiTethering, title = "Gateway paired", subtitle = "Mac Studio - Tailnet") {
|
||||
MetricRow(label = "Node", value = "Android Pixel 9")
|
||||
MetricRow(label = "Transport", value = "Secure WebSocket")
|
||||
MetricRow(label = "Capabilities", value = "Chat, Talk, Camera, Screen")
|
||||
}
|
||||
CompactList(
|
||||
title = "Ready",
|
||||
rows =
|
||||
listOf(
|
||||
"Push wakes active",
|
||||
"Approvals synced",
|
||||
"Device tools available",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatScene() {
|
||||
ChatBubble(label = "You", text = "Summarize the launch checklist before I start the release.")
|
||||
ChatBubble(
|
||||
label = "OpenClaw",
|
||||
text = "Android archive, Play metadata, and internal testing upload are ready. Screenshots are being refreshed now.",
|
||||
raised = true,
|
||||
)
|
||||
CompactList(
|
||||
title = "Working set",
|
||||
rows = listOf("Release notes", "Play bundle", "Device screenshots"),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceScene() {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
modifier = Modifier.size(196.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.primary,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
FeaturePanel(icon = Icons.Default.Mic, title = "Talk mode", subtitle = "Listening on device") {
|
||||
MetricRow(label = "Wake phrase", value = "OpenClaw")
|
||||
MetricRow(label = "Latency", value = "Realtime")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenScene() {
|
||||
FeaturePanel(icon = Icons.AutoMirrored.Filled.ScreenShare, title = "Screen tools", subtitle = "Shared with your gateway") {
|
||||
MetricRow(label = "Canvas", value = "Available")
|
||||
MetricRow(label = "Camera", value = "Permission granted")
|
||||
MetricRow(label = "Location", value = "On request")
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(168.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Live context", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ContextBar(label = "Camera", fraction = 0.74f)
|
||||
ContextBar(label = "Screen", fraction = 0.58f)
|
||||
ContextBar(label = "Location", fraction = 0.38f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScene() {
|
||||
CompactList(
|
||||
title = "Security",
|
||||
rows = listOf("Biometric lock enabled", "Gateway token encrypted", "Tool approvals required"),
|
||||
)
|
||||
CompactList(
|
||||
title = "Notifications",
|
||||
rows = listOf("Gateway status", "Approval requests", "Background presence"),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturePanel(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
IconBox(icon = icon)
|
||||
Column {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactList(
|
||||
title: String,
|
||||
rows: List<String>,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
rows.forEach { row ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(text = row, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(
|
||||
label: String,
|
||||
text: String,
|
||||
raised: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (raised) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (raised) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
Text(text = text, style = ClawTheme.type.body, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricRow(
|
||||
label: String,
|
||||
value: String,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = label, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(
|
||||
text = value,
|
||||
style = ClawTheme.type.label,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextBar(
|
||||
label: String,
|
||||
fraction: Float,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(7.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(ClawTheme.colors.surfacePressed),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(7.dp)
|
||||
.background(ClawTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenshotTabBar(activeScene: AndroidScreenshotScene) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
TabIcon(icon = Icons.Default.CheckCircle, active = activeScene == AndroidScreenshotScene.Connect)
|
||||
TabIcon(icon = Icons.Default.ChatBubble, active = activeScene == AndroidScreenshotScene.Chat)
|
||||
TabIcon(icon = Icons.Default.Mic, active = activeScene == AndroidScreenshotScene.Voice)
|
||||
TabIcon(icon = Icons.AutoMirrored.Filled.ScreenShare, active = activeScene == AndroidScreenshotScene.Screen)
|
||||
TabIcon(icon = Icons.Default.Settings, active = activeScene == AndroidScreenshotScene.Settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabIcon(
|
||||
icon: ImageVector,
|
||||
active: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (active) ClawTheme.colors.surfacePressed else Color.Transparent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconBox(icon: ImageVector) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ClawTheme.colors.surfacePressed),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusPill(
|
||||
label: String,
|
||||
color: Color,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(color))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sceneTitle(scene: AndroidScreenshotScene): String =
|
||||
when (scene) {
|
||||
AndroidScreenshotScene.Connect -> "Connect"
|
||||
AndroidScreenshotScene.Chat -> "Chat"
|
||||
AndroidScreenshotScene.Voice -> "Talk"
|
||||
AndroidScreenshotScene.Screen -> "Device tools"
|
||||
AndroidScreenshotScene.Settings -> "Settings"
|
||||
}
|
||||
@@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
|
||||
@Composable
|
||||
@@ -95,6 +96,7 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -196,6 +198,7 @@ fun ChatScreen(
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
@@ -685,6 +688,7 @@ private fun ChatComposer(
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
@@ -699,7 +703,11 @@ private fun ChatComposer(
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
ChatContextMeter(
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
@@ -735,8 +743,10 @@ private fun ChatComposer(
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -755,7 +765,13 @@ private fun ChatContextMeter(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -768,7 +784,7 @@ private fun ChatContextMeter(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.fillMaxWidth(contextFraction)
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
@@ -902,6 +918,32 @@ private fun isActiveSessionChoice(
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
internal data class ChatContextUsage(
|
||||
val totalTokens: Long?,
|
||||
val totalTokensFresh: Boolean?,
|
||||
val contextTokens: Long?,
|
||||
)
|
||||
|
||||
internal fun resolveChatContextUsage(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): ChatContextUsage {
|
||||
val entry =
|
||||
sessions.firstOrNull {
|
||||
isActiveSessionChoice(
|
||||
choiceKey = it.key,
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
contextTokens = entry?.contextTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
@@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String =
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Maps thinking presets to the visual context meter fill fraction. */
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
|
||||
if (usage.totalTokensFresh == false) return null
|
||||
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
|
||||
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
|
||||
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
internal fun contextMeterLabel(
|
||||
usage: ChatContextUsage,
|
||||
thinkingLevel: String,
|
||||
): String {
|
||||
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
|
||||
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
|
||||
}
|
||||
|
||||
internal fun contextMeterThinkingLabel(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class AndroidScreenshotModeTest {
|
||||
@Test
|
||||
fun ignoresNormalLaunches() {
|
||||
assertNull(parseAndroidScreenshotModeIntent(Intent(Intent.ACTION_MAIN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesRequestedScene() {
|
||||
val parsed =
|
||||
parseAndroidScreenshotModeIntent(
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.putExtra(extraAndroidScreenshotMode, true)
|
||||
.putExtra(extraAndroidScreenshotScene, "voice"),
|
||||
)
|
||||
|
||||
assertEquals(AndroidScreenshotScene.Voice, parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsUnknownScenesToConnect() {
|
||||
val parsed =
|
||||
parseAndroidScreenshotModeIntent(
|
||||
Intent(Intent.ACTION_MAIN)
|
||||
.putExtra(extraAndroidScreenshotMode, true)
|
||||
.putExtra(extraAndroidScreenshotScene, "unknown"),
|
||||
)
|
||||
|
||||
assertEquals(AndroidScreenshotScene.Connect, parsed)
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,96 @@ class ChatControllerSessionPolicyTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
hasContextUsageMetadata = false,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals("agent:main:phone", merged.key)
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(null, merged.totalTokens)
|
||||
assertEquals(null, merged.totalTokensFresh)
|
||||
assertEquals(null, merged.contextTokens)
|
||||
assertFalse(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 120_000L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeChatSessionEntry(
|
||||
existing = existing,
|
||||
next = next,
|
||||
preserveExistingContextUsageWithoutTotal = true,
|
||||
)
|
||||
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(41_000L, merged.totalTokens)
|
||||
assertEquals(true, merged.totalTokensFresh)
|
||||
assertEquals(120_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
totalTokens = 82_000L,
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals(82_000L, merged.totalTokens)
|
||||
assertEquals(false, merged.totalTokensFresh)
|
||||
assertEquals(100_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ChatContextMeterTest {
|
||||
@Test
|
||||
fun contextMeterUsesActiveSessionTokenBudget() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:mobile:test-device",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 1_250L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 5_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "agent:main:mobile:test-device",
|
||||
mainSessionKey = "main",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
|
||||
assertEquals(0.25f, contextMeterWidth(usage))
|
||||
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterResolvesCanonicalMainAlias() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:node-phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Main",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:main:node-phone",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
|
||||
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
|
||||
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterClampsOverfullSessions() {
|
||||
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
|
||||
|
||||
assertEquals(1.0f, contextMeterWidth(usage))
|
||||
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotDisplayStaleTokenUsage() {
|
||||
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
}
|
||||
20
apps/android/fastlane/.env.example
Normal file
20
apps/android/fastlane/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Google Play API key (pick one approach)
|
||||
#
|
||||
# Recommended local path:
|
||||
# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
|
||||
#
|
||||
# Or raw JSON content for CI:
|
||||
# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...}
|
||||
|
||||
# Optional app targeting
|
||||
# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
|
||||
|
||||
# Release target
|
||||
# GOOGLE_PLAY_TRACK=internal
|
||||
# GOOGLE_PLAY_RELEASE_STATUS=completed
|
||||
# GOOGLE_PLAY_VALIDATE_ONLY=1
|
||||
|
||||
# Metadata toggles
|
||||
# SUPPLY_UPLOAD_METADATA=1
|
||||
# SUPPLY_UPLOAD_IMAGES=1
|
||||
# SUPPLY_UPLOAD_SCREENSHOTS=1
|
||||
3
apps/android/fastlane/Appfile
Normal file
3
apps/android/fastlane/Appfile
Normal file
@@ -0,0 +1,3 @@
|
||||
package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app")
|
||||
|
||||
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"]
|
||||
274
apps/android/fastlane/Fastfile
Normal file
274
apps/android/fastlane/Fastfile
Normal file
@@ -0,0 +1,274 @@
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
require "supply/client"
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
|
||||
DEFAULT_PLAY_TRACK = "internal"
|
||||
DEFAULT_PLAY_RELEASE_STATUS = "completed"
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
|
||||
File.foreach(path) do |line|
|
||||
stripped = line.strip
|
||||
next if stripped.empty? || stripped.start_with?("#")
|
||||
|
||||
key, value = stripped.split("=", 2)
|
||||
next if key.nil? || key.empty? || value.nil?
|
||||
|
||||
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
|
||||
end
|
||||
end
|
||||
|
||||
def env_present?(value)
|
||||
!value.nil? && !value.strip.empty?
|
||||
end
|
||||
|
||||
def android_root
|
||||
File.expand_path("..", __dir__)
|
||||
end
|
||||
|
||||
def repo_root
|
||||
File.expand_path("../..", android_root)
|
||||
end
|
||||
|
||||
def shell_join(args)
|
||||
args.shelljoin
|
||||
end
|
||||
|
||||
def play_package_name
|
||||
raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip
|
||||
raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw
|
||||
end
|
||||
|
||||
def play_track
|
||||
raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip
|
||||
raw.empty? ? DEFAULT_PLAY_TRACK : raw
|
||||
end
|
||||
|
||||
def play_release_status
|
||||
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
|
||||
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
|
||||
end
|
||||
|
||||
def play_validate_only?
|
||||
ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1"
|
||||
end
|
||||
|
||||
def play_metadata_upload_requested?
|
||||
ENV["SUPPLY_UPLOAD_METADATA"] == "1"
|
||||
end
|
||||
|
||||
def play_screenshot_upload_requested?
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def play_image_upload_requested?
|
||||
ENV["SUPPLY_UPLOAD_IMAGES"] == "1"
|
||||
end
|
||||
|
||||
def play_auth_options
|
||||
json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip
|
||||
json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty?
|
||||
json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty?
|
||||
return { json_key: json_key } unless json_key.empty?
|
||||
|
||||
json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip
|
||||
json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty?
|
||||
return { json_key_data: json_key_data } unless json_key_data.empty?
|
||||
|
||||
UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.")
|
||||
end
|
||||
|
||||
def validate_play_auth!
|
||||
client = nil
|
||||
begin
|
||||
client = Supply::Client.make_from_config(params: play_auth_options)
|
||||
client.begin_edit(package_name: play_package_name)
|
||||
rescue => e
|
||||
UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}")
|
||||
ensure
|
||||
if client&.current_edit
|
||||
begin
|
||||
client.abort_current_edit
|
||||
rescue => e
|
||||
UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def read_android_version_metadata
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"node",
|
||||
"--import",
|
||||
"tsx",
|
||||
File.join(repo_root, "scripts", "android-version.ts"),
|
||||
"--json",
|
||||
"--root",
|
||||
repo_root
|
||||
)
|
||||
unless status.success?
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to read Android version metadata: #{detail}")
|
||||
end
|
||||
|
||||
parsed = JSON.parse(stdout)
|
||||
version = parsed.fetch("canonicalVersion").to_s
|
||||
version_code = parsed.fetch("versionCode").to_i
|
||||
UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0
|
||||
|
||||
{ version: version, version_code: version_code }
|
||||
rescue JSON::ParserError => e
|
||||
UI.user_error!("Invalid JSON from Android version helper: #{e.message}")
|
||||
end
|
||||
|
||||
def sync_android_versioning!
|
||||
sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check", "--root", repo_root]))
|
||||
end
|
||||
|
||||
def android_release_notes_path
|
||||
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
|
||||
end
|
||||
|
||||
def android_changelog_path(version_code)
|
||||
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
|
||||
end
|
||||
|
||||
def sync_android_changelog!(version_code)
|
||||
release_notes_path = android_release_notes_path
|
||||
UI.user_error!("Missing Android release notes at #{release_notes_path}.") unless File.exist?(release_notes_path)
|
||||
|
||||
changelog_path = android_changelog_path(version_code)
|
||||
FileUtils.mkdir_p(File.dirname(changelog_path))
|
||||
File.write(changelog_path, File.read(release_notes_path))
|
||||
changelog_path
|
||||
end
|
||||
|
||||
def play_metadata_path
|
||||
File.join(__dir__, "metadata", "android")
|
||||
end
|
||||
|
||||
def play_screenshot_paths
|
||||
Dir[File.join(play_metadata_path, "**", "images", "**", "*.png")]
|
||||
end
|
||||
|
||||
def validate_android_screenshots!
|
||||
return unless play_screenshot_upload_requested?
|
||||
|
||||
if play_screenshot_paths.empty?
|
||||
UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no PNG screenshots were found under apps/android/fastlane/metadata/android/*/images.")
|
||||
end
|
||||
end
|
||||
|
||||
def release_artifact_path(version)
|
||||
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
|
||||
end
|
||||
|
||||
def build_release_artifacts!
|
||||
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
|
||||
end
|
||||
|
||||
def capture_android_screenshots!
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
|
||||
end
|
||||
|
||||
def upload_play_store_metadata!(version_metadata)
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
|
||||
upload_to_play_store(
|
||||
**play_auth_options,
|
||||
package_name: play_package_name,
|
||||
track: play_track,
|
||||
version_code: version_metadata.fetch(:version_code),
|
||||
metadata_path: play_metadata_path,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: !play_metadata_upload_requested?,
|
||||
skip_upload_changelogs: false,
|
||||
skip_upload_images: !play_image_upload_requested?,
|
||||
skip_upload_screenshots: !play_screenshot_upload_requested?,
|
||||
validate_only: play_validate_only?
|
||||
)
|
||||
end
|
||||
|
||||
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
artifact_path = release_artifact_path(version_metadata.fetch(:version))
|
||||
UI.user_error!("Missing Play release artifact at #{artifact_path}. Run pnpm android:release:archive first.") unless File.exist?(artifact_path)
|
||||
|
||||
upload_to_play_store(
|
||||
**play_auth_options,
|
||||
package_name: play_package_name,
|
||||
aab: artifact_path,
|
||||
track: play_track,
|
||||
release_status: play_release_status,
|
||||
metadata_path: play_metadata_path,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_metadata: !upload_metadata,
|
||||
skip_upload_changelogs: false,
|
||||
skip_upload_images: !upload_images,
|
||||
skip_upload_screenshots: !upload_screenshots,
|
||||
validate_only: play_validate_only?
|
||||
)
|
||||
end
|
||||
|
||||
load_env_file(File.join(__dir__, ".env"))
|
||||
|
||||
platform :android do
|
||||
desc "Validate Google Play API credentials"
|
||||
lane :auth_check do
|
||||
validate_play_auth!
|
||||
UI.success("Google Play API credentials are valid.")
|
||||
end
|
||||
|
||||
desc "Upload Google Play metadata, changelog, and optional screenshots"
|
||||
lane :metadata do
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA")
|
||||
upload_play_store_metadata!(version_metadata)
|
||||
UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
|
||||
end
|
||||
|
||||
desc "Build signed Android release artifacts locally without uploading"
|
||||
lane :play_store_archive do
|
||||
sync_android_versioning!
|
||||
build_release_artifacts!
|
||||
end
|
||||
|
||||
desc "Generate deterministic Android screenshots for Google Play metadata"
|
||||
lane :screenshots do
|
||||
capture_android_screenshots!
|
||||
end
|
||||
|
||||
desc "Upload the signed Play AAB to Google Play"
|
||||
lane :play_store do
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
upload_play_store_build!(version_metadata)
|
||||
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
|
||||
end
|
||||
|
||||
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
|
||||
lane :release_upload do
|
||||
auth_check
|
||||
sync_android_versioning!
|
||||
version_metadata = read_android_version_metadata
|
||||
screenshots
|
||||
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
|
||||
build_release_artifacts!
|
||||
upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true)
|
||||
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
|
||||
UI.important("Production promotion remains manual in Google Play Console.")
|
||||
end
|
||||
end
|
||||
74
apps/android/fastlane/SETUP.md
Normal file
74
apps/android/fastlane/SETUP.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# fastlane setup (OpenClaw Android)
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
brew install fastlane
|
||||
```
|
||||
|
||||
Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console.
|
||||
|
||||
Recommended local auth:
|
||||
|
||||
```bash
|
||||
GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
|
||||
```
|
||||
|
||||
Optional app targeting:
|
||||
|
||||
```bash
|
||||
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
|
||||
```
|
||||
|
||||
Validate auth:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
fastlane android auth_check
|
||||
```
|
||||
|
||||
Archive locally without upload:
|
||||
|
||||
```bash
|
||||
pnpm android:release:archive
|
||||
```
|
||||
|
||||
Generate deterministic Google Play screenshots:
|
||||
|
||||
```bash
|
||||
pnpm android:screenshots
|
||||
```
|
||||
|
||||
Upload metadata, release notes, and the Play AAB to the internal testing track:
|
||||
|
||||
```bash
|
||||
pnpm android:release:upload
|
||||
```
|
||||
|
||||
Direct Fastlane entry point:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
fastlane android release_upload
|
||||
```
|
||||
|
||||
Release rules:
|
||||
|
||||
- `apps/android/version.json` is the pinned Android release version source.
|
||||
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
|
||||
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
|
||||
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
|
||||
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
|
||||
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
|
||||
- `pnpm android:version:sync` updates generated version artifacts.
|
||||
- `pnpm android:version:check` validates checked-in Android version artifacts.
|
||||
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
|
||||
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
|
||||
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
|
||||
- Production promotion remains manual in Google Play Console.
|
||||
|
||||
Screenshots:
|
||||
|
||||
- Android screenshot capture writes raw Play screenshots under `apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/`.
|
||||
- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`.
|
||||
- Do not commit generated screenshot captures unless they become intentional store metadata assets.
|
||||
@@ -0,0 +1,3 @@
|
||||
OpenClaw is now available on Android.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
@@ -0,0 +1,18 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation.
|
||||
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from Android
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your phone
|
||||
- Enable device capabilities such as camera, screen, location, and notifications when you choose
|
||||
- Receive push wakes and node status updates for connected workflows
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the Android app and pair with your gateway
|
||||
3) Start using chat, Talk mode, approvals, and automations from your phone
|
||||
@@ -0,0 +1,3 @@
|
||||
OpenClaw is now available on Android.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
@@ -0,0 +1 @@
|
||||
Personal AI on your Android devices
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Android release helper that bumps version fields, builds release AAB variants,
|
||||
* verifies signatures, and prints SHA-256 checksums.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const androidDir = join(scriptDir, "..");
|
||||
const buildGradlePath = join(androidDir, "app", "build.gradle.kts");
|
||||
const releaseOutputDir = join(androidDir, "build", "release-bundles");
|
||||
|
||||
const releaseVariants = [
|
||||
{
|
||||
flavorName: "play",
|
||||
gradleTask: ":app:bundlePlayRelease",
|
||||
bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"),
|
||||
},
|
||||
{
|
||||
flavorName: "third-party",
|
||||
gradleTask: ":app:bundleThirdPartyRelease",
|
||||
bundlePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"bundle",
|
||||
"thirdPartyRelease",
|
||||
"app-thirdParty-release.aab",
|
||||
),
|
||||
},
|
||||
] as const;
|
||||
|
||||
type VersionState = {
|
||||
versionName: string;
|
||||
versionCode: number;
|
||||
};
|
||||
|
||||
type ParsedVersionMatches = {
|
||||
versionNameMatch: RegExpMatchArray;
|
||||
versionCodeMatch: RegExpMatchArray;
|
||||
};
|
||||
|
||||
function formatVersionName(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
function formatVersionCodePrefix(date: Date): string {
|
||||
const year = date.getFullYear().toString();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function parseVersionMatches(buildGradleText: string): ParsedVersionMatches {
|
||||
const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/);
|
||||
const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/);
|
||||
if (!versionCodeMatch || !versionNameMatch) {
|
||||
throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`);
|
||||
}
|
||||
return { versionCodeMatch, versionNameMatch };
|
||||
}
|
||||
|
||||
function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number {
|
||||
const currentRaw = currentVersionCode.toString();
|
||||
let nextSuffix = 0;
|
||||
|
||||
if (currentRaw.startsWith(todayPrefix)) {
|
||||
const suffixRaw = currentRaw.slice(todayPrefix.length);
|
||||
nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) {
|
||||
throw new Error(
|
||||
`Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`,
|
||||
);
|
||||
}
|
||||
|
||||
return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10);
|
||||
}
|
||||
|
||||
function resolveNextVersion(buildGradleText: string, date: Date): VersionState {
|
||||
const { versionCodeMatch } = parseVersionMatches(buildGradleText);
|
||||
const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10);
|
||||
if (!Number.isInteger(currentVersionCode)) {
|
||||
throw new Error(`Invalid Android versionCode in ${buildGradlePath}`);
|
||||
}
|
||||
|
||||
const versionName = formatVersionName(date);
|
||||
const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date));
|
||||
return { versionName, versionCode };
|
||||
}
|
||||
|
||||
function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string {
|
||||
return buildGradleText
|
||||
.replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`)
|
||||
.replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`);
|
||||
}
|
||||
|
||||
async function sha256Hex(path: string): Promise<string> {
|
||||
const buffer = await Bun.file(path).arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function verifyBundleSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
const sourceFile = Bun.file(sourcePath);
|
||||
if (!(await sourceFile.exists())) {
|
||||
throw new Error(`Signed bundle missing at ${sourcePath}`);
|
||||
}
|
||||
|
||||
await Bun.write(destinationPath, sourceFile);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const buildGradleFile = Bun.file(buildGradlePath);
|
||||
const originalText = await buildGradleFile.text();
|
||||
const nextVersion = resolveNextVersion(originalText, new Date());
|
||||
const updatedText = updateBuildGradleVersions(originalText, nextVersion);
|
||||
|
||||
if (updatedText === originalText) {
|
||||
throw new Error("Android version bump produced no change");
|
||||
}
|
||||
|
||||
console.log(`Android versionName -> ${nextVersion.versionName}`);
|
||||
console.log(`Android versionCode -> ${nextVersion.versionCode}`);
|
||||
|
||||
await Bun.write(buildGradlePath, updatedText);
|
||||
await $`mkdir -p ${releaseOutputDir}`;
|
||||
|
||||
try {
|
||||
await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir);
|
||||
} catch (error) {
|
||||
await Bun.write(buildGradlePath, originalText);
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const variant of releaseVariants) {
|
||||
const outputPath = join(
|
||||
releaseOutputDir,
|
||||
`openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`,
|
||||
);
|
||||
|
||||
await copyBundle(variant.bundlePath, outputPath);
|
||||
await verifyBundleSignature(outputPath);
|
||||
const hash = await sha256Hex(outputPath);
|
||||
|
||||
console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`);
|
||||
console.log(`SHA-256 (${variant.flavorName}): ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
209
apps/android/scripts/build-release-artifacts.ts
Normal file
209
apps/android/scripts/build-release-artifacts.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Android release helper that builds signed release artifacts from the pinned
|
||||
* version metadata, verifies signatures, and writes SHA-256 checksum files.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
|
||||
|
||||
type ReleaseArtifact = {
|
||||
flavorName: "play" | "third-party";
|
||||
kind: "aab" | "apk";
|
||||
gradleTask: string;
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
type CliOptions = {
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const androidDir = join(scriptDir, "..");
|
||||
const rootDir = join(androidDir, "..", "..");
|
||||
const releaseOutputDir = join(androidDir, "build", "release-artifacts");
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let dryRun = false;
|
||||
|
||||
for (const arg of argv) {
|
||||
switch (arg) {
|
||||
case "--dry-run": {
|
||||
dryRun = true;
|
||||
break;
|
||||
}
|
||||
case "-h":
|
||||
case "--help": {
|
||||
console.log(
|
||||
[
|
||||
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]",
|
||||
"",
|
||||
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dryRun };
|
||||
}
|
||||
|
||||
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
|
||||
return [
|
||||
{
|
||||
flavorName: "play",
|
||||
kind: "aab",
|
||||
gradleTask: ":app:bundlePlayRelease",
|
||||
sourcePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"bundle",
|
||||
"playRelease",
|
||||
"app-play-release.aab",
|
||||
),
|
||||
},
|
||||
{
|
||||
flavorName: "third-party",
|
||||
kind: "apk",
|
||||
gradleTask: ":app:assembleThirdPartyRelease",
|
||||
sourcePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"apk",
|
||||
"thirdParty",
|
||||
"release",
|
||||
`openclaw-${versionName}-thirdParty-release.apk`,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function sha256Hex(path: string): Promise<string> {
|
||||
const buffer = await Bun.file(path).arrayBuffer();
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function writeSha256File(path: string): Promise<string> {
|
||||
const hash = await sha256Hex(path);
|
||||
const checksumPath = `${path}.sha256`;
|
||||
await Bun.write(checksumPath, `${hash} ${basename(path)}\n`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verifyAabSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
|
||||
if (!sdkRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buildToolsDir = join(sdkRoot, "build-tools");
|
||||
if (!existsSync(buildToolsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = readdirSync(buildToolsDir)
|
||||
.toSorted((left, right) => right.localeCompare(left))
|
||||
.map((version) => join(buildToolsDir, version, "apksigner"))
|
||||
.filter((candidate) => existsSync(candidate));
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
async function resolveApkSigner(): Promise<string> {
|
||||
const sdkApkSigner =
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ??
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT);
|
||||
if (sdkApkSigner) {
|
||||
return sdkApkSigner;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await $`command -v apksigner`.text()).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyApkSignature(path: string): Promise<void> {
|
||||
const apkSigner = await resolveApkSigner();
|
||||
const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await apkSignerProcess.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`apksigner verification failed for ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArtifact(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
const sourceFile = Bun.file(sourcePath);
|
||||
if (!(await sourceFile.exists())) {
|
||||
throw new Error(`Signed release artifact missing at ${sourcePath}`);
|
||||
}
|
||||
|
||||
await Bun.write(destinationPath, sourceFile);
|
||||
}
|
||||
|
||||
async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise<void> {
|
||||
if (artifact.kind === "aab") {
|
||||
await verifyAabSignature(outputPath);
|
||||
} else {
|
||||
await verifyApkSignature(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
syncAndroidVersioning({ mode: "check", rootDir });
|
||||
const version = resolveAndroidVersion(rootDir);
|
||||
const artifacts = releaseArtifacts(version.canonicalVersion);
|
||||
|
||||
console.log(`Android versionName: ${version.canonicalVersion}`);
|
||||
console.log(`Android versionCode: ${version.versionCode}`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`);
|
||||
console.log(`Gradle task: ${artifact.gradleTask}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log("Dry run complete. No Gradle tasks were executed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await $`mkdir -p ${releaseOutputDir}`;
|
||||
await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir);
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const outputPath = join(
|
||||
releaseOutputDir,
|
||||
`openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`,
|
||||
);
|
||||
|
||||
await copyArtifact(artifact.sourcePath, outputPath);
|
||||
await verifyArtifactSignature(artifact, outputPath);
|
||||
const hash = await writeSha256File(outputPath);
|
||||
|
||||
console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`);
|
||||
console.log(`SHA-256 (${artifact.flavorName}): ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
4
apps/android/version.json
Normal file
4
apps/android/version.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "2026.6.2",
|
||||
"versionCode": 2026060201
|
||||
}
|
||||
18
apps/ios/.periphery.yml
Normal file
18
apps/ios/.periphery.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
project: OpenClaw.xcodeproj
|
||||
schemes:
|
||||
- OpenClaw
|
||||
retain_codable_properties: true
|
||||
retain_swift_ui_previews: true
|
||||
retain_objc_accessible: true
|
||||
retain_unused_protocol_func_params: true
|
||||
retain_assign_only_properties: true
|
||||
relative_results: true
|
||||
disable_update_check: true
|
||||
report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchExtension/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.12 - 2026-05-12
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.10 - 2026-05-10
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
|
||||
|
||||
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.19 - 2026-04-19
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.18 - 2026-04-18
|
||||
|
||||
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.14 - 2026-04-14
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.12 - 2026-04-12
|
||||
|
||||
|
||||
53
apps/ios/Config/AppStoreSigning.json
Normal file
53
apps/ios/Config/AppStoreSigning.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"teamId": "FWJYW4S8P8",
|
||||
"signingRepo": "git@github.com:openclaw/apps-signing.git",
|
||||
"signingBranch": "main",
|
||||
"profileType": "appstore",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
"displayName": "OpenClaw",
|
||||
"bundleId": "ai.openclawfoundation.app",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
"displayName": "OpenClaw Share",
|
||||
"bundleId": "ai.openclawfoundation.app.share",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
"displayName": "OpenClaw Activity Widget",
|
||||
"bundleId": "ai.openclawfoundation.app.activitywidget",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchApp",
|
||||
"displayName": "OpenClaw Watch App",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
// Shared iOS signing defaults for local development + CI.
|
||||
#include "Version.xcconfig"
|
||||
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
|
||||
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -18,7 +20,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
#include? "../LocalSigning.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
|
||||
|
||||
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// This file is only an example and should stay committed.
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
|
||||
|
||||
## Distribution Status
|
||||
|
||||
- Public distribution: not available.
|
||||
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
|
||||
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
|
||||
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
|
||||
- Local/manual deploy from source via Xcode remains the default development path.
|
||||
|
||||
## Super-Alpha Disclaimer
|
||||
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
|
||||
pnpm ios:open
|
||||
```
|
||||
|
||||
## Local Beta Release Flow
|
||||
## App Store Release Flow
|
||||
|
||||
Prereqs:
|
||||
|
||||
@@ -55,51 +55,82 @@ Prereqs:
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- `fastlane`
|
||||
- Apple account signed into Xcode for automatic signing/provisioning
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
|
||||
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
|
||||
- Fastlane Apple Developer Portal session for the canonical OpenClaw team when creating bundle IDs or enabling services
|
||||
- Release-owner access to the encrypted signing repo password (`MATCH_PASSWORD`)
|
||||
- App Store Connect app already created for `ai.openclawfoundation.app`
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-app-store-connect-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
|
||||
|
||||
Release behavior:
|
||||
|
||||
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
- The pinned iOS version must use CalVer like `2026.4.10`.
|
||||
- That pinned value becomes:
|
||||
- `CFBundleShortVersionString = 2026.4.10`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for beta builds:
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:signing:plan
|
||||
pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
|
||||
MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
|
||||
```
|
||||
|
||||
Release-owner secrets:
|
||||
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
|
||||
|
||||
Prepare the generated release xcconfig/project without archiving:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:prepare -- --build-number 7
|
||||
```
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta:archive
|
||||
pnpm ios:release:archive
|
||||
```
|
||||
|
||||
Archive and upload to TestFlight:
|
||||
Archive and upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
If you need to force a specific build number:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta -- --build-number 7
|
||||
pnpm ios:release:upload -- --build-number 7
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
|
||||
|
||||
1. Confirm Fastlane auth is set up:
|
||||
|
||||
@@ -111,46 +142,58 @@ fastlane ios auth_check
|
||||
2. If auth is missing, bootstrap it once on this Mac:
|
||||
|
||||
```bash
|
||||
scripts/ios-asc-keychain-setup.sh \
|
||||
scripts/ios-app-store-connect-keychain-setup.sh \
|
||||
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
|
||||
--issuer-id YOUR_ISSUER_ID \
|
||||
--write-env
|
||||
```
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
This should create `apps/ios/fastlane/.env` with non-secret App Store Connect variables while the private key stays in Keychain.
|
||||
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
|
||||
- `ai.openclawfoundation.app`
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
- `ai.openclawfoundation.app.watchkitapp.extension`
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload the beta:
|
||||
6. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
6. Expected behavior:
|
||||
7. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next TestFlight build number for that short version
|
||||
- generates `apps/ios/build/BetaRelease.xcconfig`
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- uploads the IPA to TestFlight
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -176,7 +219,7 @@ Recommended flow:
|
||||
1. Keep `apps/ios/version.json` pinned to the current train version.
|
||||
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
|
||||
3. Run `pnpm ios:version:sync` after changelog changes.
|
||||
4. Upload more TestFlight builds with `pnpm ios:beta`.
|
||||
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
|
||||
5. Let Fastlane bump only the numeric build number.
|
||||
|
||||
### Starting the next production release train
|
||||
@@ -189,7 +232,7 @@ pnpm ios:version:pin -- --from-gateway
|
||||
|
||||
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
|
||||
3. Run `pnpm ios:version:sync`.
|
||||
4. Submit the first TestFlight build for that newly pinned version.
|
||||
4. Submit the first App Store Connect build for that newly pinned version.
|
||||
5. Keep iterating on that same version until the release candidate is ready.
|
||||
|
||||
See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
@@ -197,9 +240,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
## APNs Expectations For Local/Manual Builds
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
@@ -319,7 +362,7 @@ Automatic wake/reconnect hardening:
|
||||
5. If network path is unclear:
|
||||
- switch to manual host/port + TLS in Gateway Advanced settings
|
||||
6. In Xcode console, filter for subsystem/category signals:
|
||||
- `ai.openclaw.ios`
|
||||
- `ai.openclawfoundation.app`
|
||||
- `GatewayDiag`
|
||||
- `APNs registration failed`
|
||||
7. Validate background expectations:
|
||||
|
||||
@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
|
||||
var attachments: [ShareAttachment]
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
|
||||
private var statusLabel: UILabel?
|
||||
private let draftTextView = UITextView()
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
#include "Config/Version.xcconfig"
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
|
||||
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -14,7 +14,50 @@ enum AppleReviewDemoMode {
|
||||
}
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
[
|
||||
LocalChatFixture.appleReviewDemo.agents
|
||||
}
|
||||
}
|
||||
|
||||
enum ScreenshotFixtureMode {
|
||||
static let gatewayName = "OpenClaw Gateway"
|
||||
static let gatewayAddress = "Mac Studio on local network"
|
||||
static let gatewayID = "screenshot-fixture-gateway"
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
LocalChatFixture.appScreenshots.agents
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalChatFixture {
|
||||
let sessionKey: String
|
||||
let sessionIDPrefix: String
|
||||
let displayName: String
|
||||
let subject: String
|
||||
let workspace: String
|
||||
let modelProvider: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let responsePrefix: String
|
||||
let seedMessages: [String]
|
||||
let agents: [AgentSummary]
|
||||
|
||||
static let appleReviewDemo = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "apple-review-demo",
|
||||
displayName: "Apple Review Demo",
|
||||
subject: "Gateway review flow",
|
||||
workspace: "Apple Review Demo",
|
||||
modelProvider: "demo",
|
||||
modelID: "local-demo",
|
||||
modelName: "Apple Review Demo",
|
||||
responsePrefix: "Demo mode is active.",
|
||||
seedMessages: [
|
||||
"""
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Main",
|
||||
@@ -25,12 +68,70 @@ enum AppleReviewDemoMode {
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium"],
|
||||
thinkingdefault: "auto"),
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
static let appScreenshots = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "screenshot-fixture",
|
||||
displayName: "Molty",
|
||||
subject: "Mobile command center",
|
||||
workspace: "OpenClaw",
|
||||
modelProvider: "openai",
|
||||
modelID: "gpt-5.5",
|
||||
modelName: "GPT-5.5",
|
||||
responsePrefix: "OpenClaw is connected to your gateway.",
|
||||
seedMessages: [
|
||||
"""
|
||||
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
|
||||
actions from your phone.
|
||||
""",
|
||||
"""
|
||||
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
|
||||
across the app.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Molty",
|
||||
identity: ["emoji": AnyCodable("M")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
AgentSummary(
|
||||
id: "research",
|
||||
name: "Research",
|
||||
identity: ["emoji": AnyCodable("RS")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "medium"),
|
||||
AgentSummary(
|
||||
id: "automation",
|
||||
name: "Automation",
|
||||
identity: ["emoji": AnyCodable("AU")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
])
|
||||
}
|
||||
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let store = AppleReviewDemoChatStore()
|
||||
struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
private let fixture: LocalChatFixture
|
||||
private let store: LocalFixtureChatStore
|
||||
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.store = LocalFixtureChatStore(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
@@ -47,9 +148,9 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "local-demo",
|
||||
name: "Apple Review Demo",
|
||||
provider: "demo",
|
||||
modelID: self.fixture.modelID,
|
||||
name: self.fixture.modelName,
|
||||
provider: self.fixture.modelProvider,
|
||||
contextWindow: 128_000),
|
||||
]
|
||||
}
|
||||
@@ -101,26 +202,102 @@ struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
func compactSession(sessionKey _: String) async throws {}
|
||||
}
|
||||
|
||||
private actor AppleReviewDemoChatStore {
|
||||
private let sessionKey = "main"
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.transport.requestHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
try await self.transport.listModels()
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.transport.listSessions(limit: limit)
|
||||
}
|
||||
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
|
||||
}
|
||||
|
||||
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
|
||||
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await self.transport.requestHealth(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
|
||||
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.transport.events()
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
try await self.transport.setActiveSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
try await self.transport.resetSession(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
try await self.transport.compactSession(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private actor LocalFixtureChatStore {
|
||||
private let fixture: LocalChatFixture
|
||||
private var messages: [OpenClawChatMessage]
|
||||
|
||||
init() {
|
||||
self.messages = AppleReviewDemoChatStore.seedMessages()
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.messages = Self.seedMessages(fixture: fixture)
|
||||
}
|
||||
|
||||
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
|
||||
try Self.decode(
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
|
||||
as: OpenClawChatCreateSessionResponse.self)
|
||||
}
|
||||
|
||||
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
|
||||
return try Self.decode(
|
||||
HistoryPayload(
|
||||
sessionKey: normalizedSessionKey,
|
||||
sessionId: "apple-review-demo-\(normalizedSessionKey)",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
|
||||
messages: self.messages,
|
||||
thinkingLevel: "auto"),
|
||||
as: OpenClawChatHistoryPayload.self)
|
||||
@@ -135,9 +312,8 @@ private actor AppleReviewDemoChatStore {
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
|
||||
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
|
||||
OpenClaw Gateway.
|
||||
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
|
||||
prepare agent actions, and keep the mobile workflow connected to the gateway.
|
||||
""",
|
||||
timestamp: now + 1))
|
||||
return try Self.decode(
|
||||
@@ -147,15 +323,15 @@ private actor AppleReviewDemoChatStore {
|
||||
|
||||
func sessions() throws -> OpenClawChatSessionsListResponse {
|
||||
let entry = OpenClawChatSessionEntry(
|
||||
key: self.sessionKey,
|
||||
key: self.fixture.sessionKey,
|
||||
kind: "chat",
|
||||
displayName: "Apple Review Demo",
|
||||
displayName: self.fixture.displayName,
|
||||
surface: "ios",
|
||||
subject: "Gateway review flow",
|
||||
subject: self.fixture.subject,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
sessionId: "apple-review-demo-main",
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
|
||||
systemSent: true,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: "auto",
|
||||
@@ -163,52 +339,51 @@ private actor AppleReviewDemoChatStore {
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto")
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingDefault: "auto",
|
||||
mainSessionKey: self.sessionKey),
|
||||
mainSessionKey: self.fixture.sessionKey),
|
||||
sessions: [entry])
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.messages = Self.seedMessages()
|
||||
self.messages = Self.seedMessages(fixture: self.fixture)
|
||||
}
|
||||
|
||||
private static func seedMessages() -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return [
|
||||
self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
timestamp: now),
|
||||
private static var thinkingOptions: [String] {
|
||||
["auto", "low", "medium", "high"]
|
||||
}
|
||||
|
||||
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
|
||||
[
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return fixture.seedMessages.enumerated().map { index, text in
|
||||
self.message(role: "assistant", text: text, timestamp: now + Double(index))
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: role,
|
||||
@@ -223,9 +398,9 @@ private actor AppleReviewDemoChatStore {
|
||||
timestamp: timestamp)
|
||||
}
|
||||
|
||||
private static func normalizedSessionKey(_ value: String) -> String {
|
||||
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
|
||||
|
||||
@@ -5,7 +5,7 @@ import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
|
||||
@@ -202,10 +202,4 @@ final class ContactsService: ContactsServicing {
|
||||
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
|
||||
@@ -303,7 +303,7 @@ extension AgentProTab {
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
@@ -557,7 +557,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
!self.appModel.isLocalGatewayFixtureEnabled &&
|
||||
self.gatewayConnected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
@@ -127,13 +125,11 @@ struct AgentProTab: View {
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
@@ -184,9 +180,6 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
@@ -195,11 +188,4 @@ struct AgentProTab: View {
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
@State private var viewModelTransportModeID = ""
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
@@ -64,6 +64,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaPadding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.navigationBarHidden(true)
|
||||
@@ -78,6 +79,10 @@ struct ChatProTab: View {
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
@@ -103,7 +108,6 @@ struct ChatProTab: View {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@@ -135,14 +139,12 @@ struct ChatProTab: View {
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
let transportModeID = self.appModel.chatTransportModeID
|
||||
guard let viewModel else {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -151,13 +153,11 @@ struct ChatProTab: View {
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
if self.viewModelTransportModeID != transportModeID {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -226,7 +226,7 @@ struct ChatProTab: View {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
|
||||
@@ -185,33 +185,3 @@ struct CommandEmptyStateRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandTaskRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Text(self.item.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
|
||||
Text(self.item.detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(width: 64, alignment: .leading)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 56)
|
||||
}
|
||||
Text(self.item.state)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +370,11 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
@@ -414,9 +413,7 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
@@ -765,9 +762,7 @@ struct CommandSessionsScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
@@ -779,11 +774,10 @@ struct CommandSessionsScreen: View {
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
if self.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.chatTransportModeID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,12 +180,11 @@ struct IPadActivityScreen: View {
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
@@ -215,9 +214,7 @@ struct IPadActivityScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
|
||||
@@ -213,32 +213,6 @@ struct IPadSkillWorkshopScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Status")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
ForEach(Self.proposalStatusFilters, id: \.self) { filter in
|
||||
Button(Self.proposalStatusFilterLabel(filter)) {
|
||||
self.statusFilter = filter
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.statusFilterLabel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(self.neutralControlTint)
|
||||
}
|
||||
}
|
||||
|
||||
private var agentScopeMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Agent")
|
||||
@@ -1130,7 +1104,6 @@ struct IPadSkillProposalRecord: Decodable {
|
||||
let description: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let proposedVersion: String
|
||||
let target: IPadSkillProposalTarget
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,6 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
|
||||
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
|
||||
static let lightCanvasBottom = Color.white
|
||||
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
|
||||
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
|
||||
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
|
||||
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
@@ -81,11 +74,6 @@ enum OpenClawBrand {
|
||||
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
|
||||
: UIColor.white
|
||||
})
|
||||
static let graphiteSoft = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
|
||||
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
|
||||
})
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
@@ -97,40 +85,6 @@ enum OpenClawBrand {
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static var toolbarChrome: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphiteElevated.opacity(0.92),
|
||||
graphite.opacity(0.78),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static func glassFill(brighten: Bool) -> Color {
|
||||
Color.black.opacity(brighten ? 0.10 : 0.22)
|
||||
}
|
||||
|
||||
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
|
||||
if active {
|
||||
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
|
||||
}
|
||||
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
|
||||
}
|
||||
|
||||
static func formSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
|
||||
colorScheme == .dark
|
||||
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
|
||||
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -5,7 +5,6 @@ enum OpenClawProMetric {
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@@ -250,13 +249,6 @@ struct OpenClawSidebarRevealButton: View {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
@@ -430,46 +422,6 @@ struct ProProgressBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProWorkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
var progress: Double?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Text(self.trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if let progress {
|
||||
ProProgressBar(progress: progress, color: self.color)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Text(self.state)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCapsule: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -553,94 +505,6 @@ struct OpenClawGatewayCompactPill: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
|
||||
Button {
|
||||
self.selection = index
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.trackFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func segmentFill(isSelected: Bool) -> Color {
|
||||
guard isSelected else { return .clear }
|
||||
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
|
||||
}
|
||||
|
||||
private var trackStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProHeroActionButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
|
||||
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
|
||||
radius: 18,
|
||||
isProminent: true,
|
||||
interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -795,24 +659,3 @@ struct ProStatusRow: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProTimelineRow: View {
|
||||
let done: Bool
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(
|
||||
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
|
||||
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
@@ -332,65 +332,6 @@ struct SettingsChannelsDestination: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
|
||||
@@ -139,15 +139,6 @@ extension SettingsProTab {
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
func refreshGateway() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
@@ -200,7 +191,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
@@ -298,7 +289,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@@ -327,7 +318,7 @@ extension SettingsProTab {
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
func preflightGateway(host: String, port: Int) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
|
||||
let queue = DispatchQueue(label: "ai.openclawfoundation.app.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
|
||||
@@ -3,7 +3,6 @@ import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import EventKit
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -126,6 +125,7 @@ final class GatewayConnectionController {
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
@@ -138,6 +138,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
@@ -147,7 +148,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if startDiscovery {
|
||||
if self.discoveryEnabled {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -157,6 +158,11 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
self.discovery.stop()
|
||||
@@ -169,12 +175,13 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func allowAutoConnectAgain() {
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
@@ -522,8 +529,7 @@ final class GatewayConnectionController {
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
|
||||
tlsEnabled: resolvedUseTLS)
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
@@ -719,8 +725,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
@@ -747,8 +752,7 @@ final class GatewayConnectionController {
|
||||
|
||||
private func resolveManualTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||
tlsEnabled: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
@@ -785,126 +789,6 @@ final class GatewayConnectionController {
|
||||
resolver.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
(host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBonjourServiceToHostPort(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||
{
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
// resume the continuation exactly once (timeout/cancel safe).
|
||||
@MainActor
|
||||
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||
private let service: NetService
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
private var finished = false
|
||||
|
||||
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||
self.cont = cont
|
||||
self.service = service
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(timeoutSeconds: TimeInterval) {
|
||||
self.service.delegate = self
|
||||
self.service.schedule(in: .main, forMode: .default)
|
||||
|
||||
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||
// callbacks never arrive (e.g. local network permission issues).
|
||||
self.timeoutTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
self.service.resolve(withTimeout: timeoutSeconds)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
self.finish(Self.extractHostPort(sender))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
private func finish(_ result: (host: String, port: Int)?) {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .default)
|
||||
|
||||
let c = self.cont
|
||||
self.cont = nil
|
||||
c?.resume(returning: result)
|
||||
}
|
||||
|
||||
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||
let port = svc.port
|
||||
|
||||
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
let rc = getnameinfo(
|
||||
base.assumingMemoryBound(to: sockaddr.self),
|
||||
socklen_t(ptr.count),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
let service = NetService(domain: domain, type: type, name: name)
|
||||
let resolver = Resolver(cont: cont, service: service)
|
||||
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
@@ -1162,30 +1046,10 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() async -> [String: Bool] {
|
||||
await self.currentPermissions()
|
||||
}
|
||||
|
||||
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
DeviceInfoHelper.platformString()
|
||||
}
|
||||
|
||||
func _test_deviceFamily() -> String {
|
||||
DeviceInfoHelper.deviceFamily()
|
||||
}
|
||||
|
||||
func _test_modelIdentifier() -> String {
|
||||
DeviceInfoHelper.modelIdentifier()
|
||||
}
|
||||
|
||||
func _test_appVersion() -> String {
|
||||
DeviceInfoHelper.appVersion()
|
||||
}
|
||||
|
||||
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
self.gateways = gateways
|
||||
}
|
||||
@@ -1199,10 +1063,9 @@ extension GatewayConnectionController {
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
}
|
||||
|
||||
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
|
||||
|
||||
@@ -59,7 +59,7 @@ final class GatewayDiscoveryModel {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
|
||||
@@ -30,27 +30,13 @@ struct GatewayQuickSetupSheet: View {
|
||||
}
|
||||
|
||||
if let candidate = self.bestCandidate {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: candidate.name)
|
||||
.font(.headline)
|
||||
Text(verbatim: candidate.debugID)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
|
||||
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
GatewayQuickSetupCandidatePanel(
|
||||
name: candidate.name,
|
||||
debugID: candidate.debugID,
|
||||
discoveryStatusText: self.gatewayController.discoveryStatusText,
|
||||
gatewayDisplayStatusText: self.appModel.gatewayDisplayStatusText,
|
||||
nodeStatusText: self.appModel.nodeStatusText,
|
||||
operatorStatusText: self.appModel.operatorStatusText)
|
||||
|
||||
Button {
|
||||
self.connectError = nil
|
||||
@@ -169,3 +155,43 @@ struct GatewayQuickSetupSheet: View {
|
||||
self.connectError = err
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayQuickSetupCandidatePanel: View {
|
||||
private static let readableMonospaceWidth: CGFloat = 72 * 8
|
||||
|
||||
let name: String
|
||||
let debugID: String
|
||||
let discoveryStatusText: String
|
||||
let gatewayDisplayStatusText: String
|
||||
let nodeStatusText: String
|
||||
let operatorStatusText: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: self.name)
|
||||
.font(.system(.headline, design: .monospaced))
|
||||
.foregroundStyle(.primary)
|
||||
Text(verbatim: self.debugID)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.gatewayDisplayStatusText)")
|
||||
Text(verbatim: "Node: \(self.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.operatorStatusText)")
|
||||
}
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: Self.readableMonospaceWidth, alignment: .leading)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@ import Foundation
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
private static let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private static let nodeService = "ai.openclawfoundation.app.node"
|
||||
private static let talkService = "ai.openclawfoundation.app.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
|
||||
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
|
||||
private static let manualHostDefaultsKey = "gateway.manual.host"
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
@@ -184,24 +179,6 @@ enum GatewaySettingsStore {
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
stableID
|
||||
case let .discovered(stableID, _):
|
||||
stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String, Codable {
|
||||
@@ -229,17 +206,6 @@ enum GatewaySettingsStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
|
||||
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
|
||||
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
||||
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(service: self.talkService, account: account)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
|
||||
@@ -477,8 +443,8 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
@@ -580,11 +546,4 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
self.queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID).bgrefresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.openclaw.ios</string>
|
||||
<string>ai.openclawfoundation.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>openclaw</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "LiveActivity")
|
||||
private let connectingStaleSeconds: TimeInterval = 120
|
||||
private let hydrationStaleSeconds: TimeInterval = 300
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
@@ -17,15 +17,6 @@ final class LiveActivityManager {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
guard let activity = self.currentActivity else { return false }
|
||||
guard activity.activityState == .active else {
|
||||
self.currentActivity = nil
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
@@ -96,10 +87,6 @@ final class LiveActivityManager {
|
||||
self.endActivity(reason: "connected")
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.endActivity(reason: "disconnected")
|
||||
}
|
||||
|
||||
func endActivity(reason: String) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
self.currentActivity = nil
|
||||
@@ -183,15 +170,6 @@ final class LiveActivityManager {
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
|
||||
@@ -14,39 +14,3 @@ struct OpenClawActivityAttributes: ActivityAttributes {
|
||||
var startedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension OpenClawActivityAttributes {
|
||||
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
|
||||
}
|
||||
|
||||
extension OpenClawActivityAttributes.ContentState {
|
||||
static let connecting = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: .now)
|
||||
|
||||
static let idle = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let disconnected = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let attention = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Approval needed",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,8 +12,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
|
||||
private var isStreaming = false
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
@@ -84,42 +82,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
self.isStreaming = true
|
||||
if significantChangesOnly {
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
} else {
|
||||
self.manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
|
||||
self.updatesContinuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { @MainActor in
|
||||
self.stopLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLocationUpdates() {
|
||||
guard self.isStreaming else { return }
|
||||
self.isStreaming = false
|
||||
self.manager.stopUpdatingLocation()
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
self.updatesContinuation?.finish()
|
||||
self.updatesContinuation = nil
|
||||
}
|
||||
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
|
||||
self.significantLocationCallback = onUpdate
|
||||
guard !self.isMonitoringSignificantChanges else { return }
|
||||
@@ -127,13 +89,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
func stopMonitoringSignificantLocationChanges() {
|
||||
guard self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = false
|
||||
self.significantLocationCallback = nil
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
@@ -161,9 +116,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
if let callback = self.significantLocationCallback, let latest = locs.last {
|
||||
callback(latest)
|
||||
}
|
||||
if let latest = locs.last, let updates = self.updatesContinuation {
|
||||
updates.yield(latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ private struct GatewayRelayIdentityResponse: Decodable {
|
||||
let publicKey: String
|
||||
}
|
||||
|
||||
private struct WatchChatPreview {
|
||||
var items: [OpenClawWatchChatItem]
|
||||
var statusText: String?
|
||||
}
|
||||
|
||||
/// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -54,6 +59,8 @@ private enum IOSDeepLinkAgentPolicy {
|
||||
@Observable
|
||||
// swiftlint:disable type_body_length file_length
|
||||
final class NodeAppModel {
|
||||
private nonisolated static let watchChatPreviewItemLimit = 5
|
||||
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
let messagePreview: String
|
||||
@@ -88,14 +95,14 @@ final class NodeAppModel {
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
subsystem: "ai.openclawfoundation.app",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
@@ -112,6 +119,7 @@ final class NodeAppModel {
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
||||
private(set) var isScreenshotFixtureModeEnabled: Bool = false
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
@@ -133,7 +141,6 @@ final class NodeAppModel {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
private var focusedChatSessionKey: String?
|
||||
var selectedAgentId: String?
|
||||
@@ -191,6 +198,8 @@ final class NodeAppModel {
|
||||
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
@ObservationIgnored private let watchChatCoordinator = WatchChatCoordinator()
|
||||
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
|
||||
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
|
||||
private var pendingWatchExecApprovalRecoveryIDs: [String] = []
|
||||
private var pendingForegroundActionDrainInFlight = false
|
||||
@@ -202,20 +211,48 @@ final class NodeAppModel {
|
||||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession {
|
||||
self.nodeGateway
|
||||
}
|
||||
|
||||
var operatorSession: GatewayNodeSession {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
var localChatFixture: LocalChatFixture? {
|
||||
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
|
||||
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isLocalChatFixtureEnabled: Bool {
|
||||
self.localChatFixture != nil
|
||||
}
|
||||
|
||||
var isLocalGatewayFixtureEnabled: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
|
||||
}
|
||||
|
||||
var chatTransportModeID: String {
|
||||
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
|
||||
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
func makeChatTransport() -> any OpenClawChatTransport {
|
||||
if self.isScreenshotFixtureModeEnabled {
|
||||
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
||||
}
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
return AppleReviewDemoChatTransport()
|
||||
}
|
||||
return IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
|
||||
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
|
||||
private static let foregroundResumeHealthTimeoutSeconds = 1
|
||||
private static let watchChatCompletionWaitMs = 45000
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
@@ -287,6 +324,19 @@ final class NodeAppModel {
|
||||
await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
|
||||
}
|
||||
}
|
||||
self.watchMessagingService.setAppSnapshotRequestHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"node app model: watch app snapshot request id=\(event.requestId)")
|
||||
await self.syncWatchAppSnapshot(reason: "watch_app_request", includeChat: true)
|
||||
}
|
||||
}
|
||||
self.watchMessagingService.setAppCommandHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handleWatchAppCommand(event)
|
||||
}
|
||||
}
|
||||
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
@@ -551,7 +601,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -724,11 +774,6 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
|
||||
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||
@@ -738,12 +783,9 @@ final class NodeAppModel {
|
||||
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let session = config["session"] as? [String: Any]
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
@@ -926,7 +968,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -1891,6 +1933,14 @@ extension NodeAppModel {
|
||||
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
|
||||
}
|
||||
|
||||
var chatAgentAvatarURL: String? {
|
||||
self.agentIdentityValue(for: self.chatAgentId, key: "avatarUrl")
|
||||
}
|
||||
|
||||
var chatAgentAvatarText: String? {
|
||||
self.agentIdentityValue(for: self.chatAgentId, key: "emoji")
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
|
||||
}
|
||||
@@ -1911,6 +1961,18 @@ extension NodeAppModel {
|
||||
return resolvedId
|
||||
}
|
||||
|
||||
private func agentIdentityValue(for agentId: String, key: String) -> String? {
|
||||
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !resolvedId.isEmpty,
|
||||
let match = self.gatewayAgents.first(where: { $0.id == resolvedId }),
|
||||
let rawValue = match.identity?[key]?.value as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func connectToGateway(
|
||||
url: URL,
|
||||
gatewayStableID: String,
|
||||
@@ -1948,7 +2010,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
self.activeGatewayConnectConfig = nextConfig
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
self.prepareForGatewayConnect(stableID: effectiveStableID)
|
||||
if operatorLoopRequired {
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
@@ -1975,6 +2037,7 @@ extension NodeAppModel {
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -2009,7 +2072,7 @@ extension NodeAppModel {
|
||||
|
||||
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.resetGatewaySessionsForForcedReconnect()
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -2020,6 +2083,7 @@ extension NodeAppModel {
|
||||
|
||||
func disconnectGateway() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2045,7 +2109,6 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
ShareGatewayRelaySettings.clearConfig()
|
||||
@@ -2054,8 +2117,9 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
private func prepareForGatewayConnect(stableID: String) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2098,7 +2162,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -2124,7 +2188,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.operatorGatewayProblem = problem
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
@@ -2357,7 +2421,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.setOperatorConnected(true)
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
@@ -2379,7 +2443,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
@@ -2404,7 +2468,7 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
||||
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
||||
@@ -2470,7 +2534,7 @@ extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
@@ -2498,7 +2562,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
@@ -2557,7 +2621,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
@@ -2607,7 +2671,7 @@ extension NodeAppModel {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
@@ -2648,7 +2712,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
@@ -2658,7 +2722,6 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
@@ -2782,9 +2845,22 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func setOperatorConnected(_ connected: Bool) {
|
||||
let changed = self.operatorConnected != connected
|
||||
self.operatorConnected = connected
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
guard connected else {
|
||||
guard changed else { return }
|
||||
Task { [weak self] in
|
||||
await self?.syncWatchAppSnapshot(reason: "operator_offline")
|
||||
}
|
||||
return
|
||||
}
|
||||
Task { [weak self] in
|
||||
await self?.flushQueuedWatchChatsIfAvailable()
|
||||
guard changed else { return }
|
||||
await self?.syncWatchAppSnapshot(reason: "operator_online")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshOperatorAdminScopeFromStore() {
|
||||
@@ -2799,6 +2875,7 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2831,7 +2908,6 @@ extension NodeAppModel {
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
@@ -2840,6 +2916,47 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
||||
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
||||
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.talkMode.enterScreenshotFixtureMode()
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -2946,18 +3063,11 @@ extension NodeAppModel {
|
||||
self.refreshLastShareEventFromRelay()
|
||||
}
|
||||
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
await self.syncWatchAppSnapshot(reason: "node_connected", includeChat: true)
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
|
||||
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||
}
|
||||
@@ -3162,10 +3272,11 @@ extension NodeAppModel {
|
||||
"watch exec approval: status changed "
|
||||
+ "reachable=\(status.reachable) activation=\(status.activationState) "
|
||||
+ "backgrounded=\(self.isBackgrounded)")
|
||||
guard self.isBackgrounded else { return }
|
||||
guard status.supported, status.paired, status.appInstalled else { return }
|
||||
guard status.reachable || status.activationState == "activated" else { return }
|
||||
let reason = status.reachable ? "watch_reachable" : "watch_activated"
|
||||
await self.syncWatchAppSnapshot(reason: reason, includeChat: status.reachable)
|
||||
guard self.isBackgrounded else { return }
|
||||
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
||||
}
|
||||
|
||||
@@ -3250,6 +3361,7 @@ extension NodeAppModel {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch approval prompt error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "\(reason)_app")
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
||||
}
|
||||
|
||||
@@ -3275,6 +3387,7 @@ extension NodeAppModel {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch approval resolve error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "resolved_app")
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
||||
}
|
||||
|
||||
@@ -3298,6 +3411,7 @@ extension NodeAppModel {
|
||||
self.watchExecApprovalLogger.error(
|
||||
"watch approval expiry error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app")
|
||||
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
||||
}
|
||||
|
||||
@@ -3340,10 +3454,311 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWatchChatPreview() async -> WatchChatPreview {
|
||||
do {
|
||||
let payload: OpenClawChatHistoryPayload
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
payload = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: self.chatSessionKey)
|
||||
} else {
|
||||
guard self.isOperatorGatewayConnected else {
|
||||
return WatchChatPreview(
|
||||
items: [],
|
||||
statusText: "Connect iPhone chat to read messages")
|
||||
}
|
||||
payload = try await IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
.requestHistory(sessionKey: self.chatSessionKey)
|
||||
}
|
||||
|
||||
let items = Self.makeWatchChatItems(from: payload.messages ?? [])
|
||||
return WatchChatPreview(
|
||||
items: items,
|
||||
statusText: items.isEmpty ? "No chat messages yet" : nil)
|
||||
} catch {
|
||||
GatewayDiagnostics.log("watch app snapshot: chat preview failed error=\(error.localizedDescription)")
|
||||
return WatchChatPreview(items: [], statusText: "Chat unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func decodeWatchChatMessage(
|
||||
_ raw: OpenClawKit.AnyCodable) -> OpenClawChatMessage?
|
||||
{
|
||||
guard let data = try? JSONEncoder().encode(raw) else { return nil }
|
||||
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
|
||||
}
|
||||
|
||||
private nonisolated static func makeWatchChatItems(
|
||||
from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
|
||||
{
|
||||
var readableMessages: [(OpenClawChatMessage, String)] = []
|
||||
for item in raw.reversed() {
|
||||
guard let message = self.decodeWatchChatMessage(item) else { continue }
|
||||
let text = self.watchChatText(from: message)
|
||||
guard !text.isEmpty else { continue }
|
||||
readableMessages.append((message, text))
|
||||
if readableMessages.count == self.watchChatPreviewItemLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return Array(readableMessages.reversed()).enumerated().map { index, entry in
|
||||
let timestampMs = self.watchTimestampMs(entry.0.timestamp)
|
||||
let stableTime = timestampMs.map(String.init) ?? entry.0.id.uuidString
|
||||
return OpenClawWatchChatItem(
|
||||
id: "\(entry.0.role)-\(stableTime)-\(index)",
|
||||
role: entry.0.role,
|
||||
text: self.truncatedWatchChatText(entry.1),
|
||||
timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String {
|
||||
let parts = message.content.compactMap { content -> String? in
|
||||
let kind = (content.type ?? "text").lowercased()
|
||||
guard kind.isEmpty || kind == "text" else { return nil }
|
||||
if let text = self.nonEmptyWatchChatText(content.text) {
|
||||
return text
|
||||
}
|
||||
if let text = self.nonEmptyWatchChatText(content.content?.value as? String) {
|
||||
return text
|
||||
}
|
||||
if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable],
|
||||
let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String)
|
||||
{
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !contentText.isEmpty {
|
||||
return contentText
|
||||
}
|
||||
return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? {
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private nonisolated static func truncatedWatchChatText(_ text: String) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 240 else { return trimmed }
|
||||
return "\(trimmed.prefix(237))..."
|
||||
}
|
||||
|
||||
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? {
|
||||
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
||||
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
||||
let maxReasonableEpochMs: Double = 32_503_680_000_000
|
||||
guard milliseconds.isFinite,
|
||||
milliseconds >= 0,
|
||||
milliseconds <= maxReasonableEpochMs
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Int(milliseconds)
|
||||
}
|
||||
|
||||
private func makeWatchAppSnapshot(
|
||||
chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage
|
||||
{
|
||||
self.pruneExpiredWatchExecApprovalPrompts()
|
||||
let watchGatewayConnected = self.isAppleReviewDemoModeEnabled
|
||||
|| (self.gatewayConnected && self.operatorConnected)
|
||||
let displayStatusText = self.gatewayDisplayStatusText
|
||||
let watchGatewayStatusText = watchGatewayConnected || displayStatusText != "Connected"
|
||||
? displayStatusText
|
||||
: self.operatorStatusText
|
||||
return OpenClawWatchAppSnapshotMessage(
|
||||
gatewayStatusText: watchGatewayStatusText,
|
||||
gatewayConnected: watchGatewayConnected,
|
||||
agentName: self.chatAgentName,
|
||||
agentAvatarURL: self.chatAgentAvatarURL,
|
||||
agentAvatarText: self.chatAgentAvatarText,
|
||||
sessionKey: self.chatSessionKey,
|
||||
gatewayStableID: self.currentWatchChatGatewayStableID(),
|
||||
talkStatusText: self.talkMode.statusText,
|
||||
talkEnabled: self.talkMode.isEnabled,
|
||||
talkListening: self.talkMode.isListening,
|
||||
talkSpeaking: self.talkMode.isSpeaking,
|
||||
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
|
||||
chatItems: chatPreview?.items,
|
||||
chatStatusText: chatPreview?.statusText,
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
snapshotId: UUID().uuidString)
|
||||
}
|
||||
|
||||
private func handleWatchAppCommand(_ event: WatchAppCommandEvent) async {
|
||||
GatewayDiagnostics.log(
|
||||
"watch app command: handle id=\(event.commandId) command=\(event.command.rawValue)")
|
||||
switch event.command {
|
||||
case .refresh:
|
||||
break
|
||||
case .openChat:
|
||||
self.openChat(sessionKey: event.sessionKey ?? self.chatSessionKey)
|
||||
case .sendChat:
|
||||
await self.handleWatchChatCommand(event)
|
||||
return
|
||||
case .startTalk:
|
||||
guard !self.isAppleReviewDemoModeEnabled else { break }
|
||||
self.talkMode.updateMainSessionKey(event.sessionKey ?? self.chatSessionKey)
|
||||
self.setTalkEnabled(true)
|
||||
case .stopTalk:
|
||||
self.setTalkEnabled(false)
|
||||
}
|
||||
await self.syncWatchAppSnapshot(
|
||||
reason: "watch_command_\(event.command.rawValue)",
|
||||
includeChat: true)
|
||||
}
|
||||
|
||||
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
|
||||
guard self.watchChatCommandTargetsCurrentGateway(event) else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
|
||||
return
|
||||
}
|
||||
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event)
|
||||
switch self.watchChatCoordinator.ingest(
|
||||
event,
|
||||
isChatAvailable: self.isWatchChatAvailableForSend(),
|
||||
gatewayStableID: eventGatewayID)
|
||||
{
|
||||
case .dropMissingFields:
|
||||
GatewayDiagnostics.log("watch chat send skipped: missing commandId/text")
|
||||
case .dropMissingTarget:
|
||||
GatewayDiagnostics.log("watch chat send skipped: missing gateway target")
|
||||
case let .deduped(commandId):
|
||||
GatewayDiagnostics.log("watch chat send deduped commandId=\(commandId)")
|
||||
case let .queue(commandId):
|
||||
GatewayDiagnostics.log("watch chat send queued commandId=\(commandId)")
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
|
||||
case .forward:
|
||||
_ = await self.forwardWatchChatMessage(event, requeueOnFailure: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func flushQueuedWatchChatsIfAvailable() async {
|
||||
let gatewayStableID = self.currentWatchChatGatewayStableID()
|
||||
while let event = self.watchChatCoordinator.nextQueuedCommand(
|
||||
isChatAvailable: self.isWatchChatAvailableForSend(),
|
||||
gatewayStableID: gatewayStableID)
|
||||
{
|
||||
guard self.watchChatCommandTargetsCurrentGateway(event) else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: stale queued gateway target")
|
||||
self.watchChatCoordinator.removeQueuedCommand(
|
||||
commandId: event.commandId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
continue
|
||||
}
|
||||
let sent = await self.forwardWatchChatMessage(event, requeueOnFailure: false)
|
||||
guard sent else { return }
|
||||
self.watchChatCoordinator.removeQueuedCommand(
|
||||
commandId: event.commandId,
|
||||
gatewayStableID: gatewayStableID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isWatchChatAvailableForSend() -> Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private func currentWatchChatGatewayStableID() -> String? {
|
||||
self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func normalizedWatchChatGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
|
||||
let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return gatewayStableID.isEmpty ? nil : gatewayStableID
|
||||
}
|
||||
|
||||
private func watchChatCommandTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
|
||||
let eventGatewayID = self.normalizedWatchChatGatewayStableID(event) ?? ""
|
||||
let currentGatewayID = self.currentWatchChatGatewayStableID() ?? ""
|
||||
guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false }
|
||||
return eventGatewayID == currentGatewayID
|
||||
}
|
||||
|
||||
private func forwardWatchChatMessage(
|
||||
_ event: WatchAppCommandEvent,
|
||||
requeueOnFailure: Bool) async -> Bool
|
||||
{
|
||||
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: empty text")
|
||||
return true
|
||||
}
|
||||
|
||||
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? event.sessionKey!
|
||||
: self.chatSessionKey
|
||||
self.focusChatSession(sessionKey)
|
||||
|
||||
do {
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
_ = try await self.appleReviewDemoChatTransport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: text,
|
||||
thinking: "auto",
|
||||
idempotencyKey: event.commandId,
|
||||
attachments: [])
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
||||
return true
|
||||
}
|
||||
|
||||
guard self.isOperatorGatewayConnected else {
|
||||
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
|
||||
if requeueOnFailure {
|
||||
self.watchChatCoordinator.requeueFront(
|
||||
event,
|
||||
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let transport = IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
let response = try await transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: text,
|
||||
thinking: "auto",
|
||||
idempotencyKey: event.commandId,
|
||||
attachments: [])
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
||||
let completed = await transport.waitForRunCompletion(
|
||||
runId: response.runId,
|
||||
timeoutMs: Self.watchChatCompletionWaitMs)
|
||||
guard completed else { return true }
|
||||
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
|
||||
return true
|
||||
} catch {
|
||||
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
|
||||
if requeueOnFailure {
|
||||
self.watchChatCoordinator.requeueFront(
|
||||
event,
|
||||
gatewayStableID: self.normalizedWatchChatGatewayStableID(event))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func syncWatchAppSnapshot(reason: String, includeChat: Bool = false) async {
|
||||
let chatPreview = includeChat ? await self.makeWatchChatPreview() : nil
|
||||
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
|
||||
do {
|
||||
_ = try await self.watchMessagingService.syncAppSnapshot(message)
|
||||
GatewayDiagnostics.log(
|
||||
"watch app snapshot: sent reason=\(reason) "
|
||||
+ "connected=\(message.gatewayConnected) approvals=\(message.pendingApprovalCount) "
|
||||
+ "chatItems=\(message.chatItems?.count ?? -1)")
|
||||
} catch {
|
||||
GatewayDiagnostics.log(
|
||||
"watch app snapshot: failed reason=\(reason) error=\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
|
||||
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
|
||||
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
|
||||
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
||||
await self.syncWatchAppSnapshot(reason: "\(reason)_app", includeChat: true)
|
||||
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
||||
}
|
||||
|
||||
@@ -3914,32 +4329,6 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: decision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
@@ -4476,17 +4865,6 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
func approvePendingAgentDeepLinkPrompt() async {
|
||||
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
|
||||
self.pendingAgentDeepLinkPrompt = nil
|
||||
@@ -4636,38 +5014,42 @@ extension NodeAppModel {
|
||||
try self.encodePayload(obj)
|
||||
}
|
||||
|
||||
func _test_isCameraEnabled() -> Bool {
|
||||
self.isCameraEnabled()
|
||||
}
|
||||
|
||||
func _test_triggerCameraFlash() {
|
||||
self.triggerCameraFlash()
|
||||
}
|
||||
|
||||
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
||||
}
|
||||
|
||||
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
||||
await self.handleCanvasA2UIAction(body: body)
|
||||
}
|
||||
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
|
||||
func _test_queuedWatchChatCommandCount() -> Int {
|
||||
self.watchChatCoordinator.queuedCount
|
||||
}
|
||||
|
||||
func _test_queuedWatchChatCommandIds() -> [String] {
|
||||
self.watchChatCoordinator.queuedCommandIds
|
||||
}
|
||||
|
||||
func _test_setConnectedGatewayID(_ gatewayID: String?) {
|
||||
self.connectedGatewayID = gatewayID
|
||||
}
|
||||
|
||||
static func _test_resetPersistedWatchChatQueueState() {
|
||||
WatchChatCoordinator.resetPersistedQueue()
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
self.gatewayConnected = connected
|
||||
}
|
||||
|
||||
func _test_setOperatorConnected(_ connected: Bool) {
|
||||
self.setOperatorConnected(connected)
|
||||
}
|
||||
|
||||
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
||||
self.makeWatchChatItems(from: raw)
|
||||
}
|
||||
|
||||
func _test_isGatewayConnected() -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
@@ -44,3 +44,160 @@ final class WatchReplyCoordinator {
|
||||
self.queuedReplies.count
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchChatCoordinator {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case dropMissingTarget
|
||||
case deduped(commandId: String)
|
||||
case queue(commandId: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
private static let persistedQueueKey = "watch.chat.command.queue.v1"
|
||||
private static let maxRecentCommandIds = 128
|
||||
|
||||
private struct QueuedCommand: Codable, Equatable {
|
||||
var gatewayStableID: String
|
||||
var event: WatchAppCommandEvent
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var queuedCommands: [QueuedCommand] = []
|
||||
private var recentCommandIds: [String] = []
|
||||
private var seenCommandIds = Set<String>()
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.restoreQueue()
|
||||
}
|
||||
|
||||
func ingest(
|
||||
_ event: WatchAppCommandEvent,
|
||||
isChatAvailable: Bool,
|
||||
gatewayStableID: String?) -> Decision
|
||||
{
|
||||
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if commandId.isEmpty || text.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenCommandIds.contains(commandId) {
|
||||
return .deduped(commandId: commandId)
|
||||
}
|
||||
self.rememberRecentCommandId(commandId)
|
||||
if !isChatAvailable {
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty else { return .dropMissingTarget }
|
||||
self.queuedCommands.append(
|
||||
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)))
|
||||
self.rebuildSeenCommandIds()
|
||||
self.persistQueue()
|
||||
return .queue(commandId: commandId)
|
||||
}
|
||||
return .forward
|
||||
}
|
||||
|
||||
func nextQueuedCommand(isChatAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard isChatAvailable, !owner.isEmpty else { return nil }
|
||||
return self.queuedCommands.first { $0.gatewayStableID == owner }?.event
|
||||
}
|
||||
|
||||
func removeQueuedCommand(commandId: String, gatewayStableID: String?) {
|
||||
let commandId = commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !commandId.isEmpty, !owner.isEmpty else { return }
|
||||
guard let index = self.queuedCommands.firstIndex(where: {
|
||||
$0.gatewayStableID == owner && $0.event.commandId == commandId
|
||||
}) else { return }
|
||||
self.queuedCommands.remove(at: index)
|
||||
self.rememberRecentCommandId(commandId)
|
||||
self.persistQueue()
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
|
||||
let commandId = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty else { return }
|
||||
if !commandId.isEmpty {
|
||||
self.rememberRecentCommandId(commandId)
|
||||
self.queuedCommands.removeAll { $0.event.commandId == commandId }
|
||||
}
|
||||
self.queuedCommands.insert(
|
||||
QueuedCommand(gatewayStableID: owner, event: self.command(event, taggedFor: owner)),
|
||||
at: 0)
|
||||
self.rebuildSeenCommandIds()
|
||||
self.persistQueue()
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedCommands.count
|
||||
}
|
||||
|
||||
var queuedCommandIds: [String] {
|
||||
self.queuedCommands.map(\.event.commandId)
|
||||
}
|
||||
|
||||
private func restoreQueue() {
|
||||
guard let data = defaults.data(forKey: Self.persistedQueueKey),
|
||||
let persisted = try? JSONDecoder().decode([QueuedCommand].self, from: data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
var seen: [String] = []
|
||||
var seenSet = Set<String>()
|
||||
self.queuedCommands = persisted.compactMap { queued in
|
||||
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let commandId = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !owner.isEmpty, !commandId.isEmpty, !text.isEmpty, seenSet.insert(commandId).inserted else {
|
||||
return nil
|
||||
}
|
||||
seen.append(commandId)
|
||||
return QueuedCommand(gatewayStableID: owner, event: self.command(queued.event, taggedFor: owner))
|
||||
}
|
||||
self.recentCommandIds = Array(seen.suffix(Self.maxRecentCommandIds))
|
||||
self.rebuildSeenCommandIds()
|
||||
if self.queuedCommands.count != persisted.count {
|
||||
self.persistQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func rememberRecentCommandId(_ commandId: String) {
|
||||
guard !commandId.isEmpty else { return }
|
||||
self.recentCommandIds.removeAll { $0 == commandId }
|
||||
self.recentCommandIds.append(commandId)
|
||||
if self.recentCommandIds.count > Self.maxRecentCommandIds {
|
||||
self.recentCommandIds.removeFirst(self.recentCommandIds.count - Self.maxRecentCommandIds)
|
||||
}
|
||||
self.rebuildSeenCommandIds()
|
||||
}
|
||||
|
||||
private func rebuildSeenCommandIds() {
|
||||
var ids = Set(self.recentCommandIds)
|
||||
ids.formUnion(self.queuedCommands.map(\.event.commandId))
|
||||
self.seenCommandIds = ids
|
||||
}
|
||||
|
||||
private func persistQueue() {
|
||||
if self.queuedCommands.isEmpty {
|
||||
self.defaults.removeObject(forKey: Self.persistedQueueKey)
|
||||
return
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(queuedCommands) else { return }
|
||||
self.defaults.set(data, forKey: Self.persistedQueueKey)
|
||||
}
|
||||
|
||||
private func command(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
|
||||
var tagged = event
|
||||
tagged.gatewayStableID = gatewayStableID
|
||||
return tagged
|
||||
}
|
||||
|
||||
static func resetPersistedQueue(defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: self.persistedQueueKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Connect to your gateway to get started.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Auto detect") {
|
||||
AutoDetectStep()
|
||||
}
|
||||
NavigationLink("Manual entry") {
|
||||
ManualEntryStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoDetectStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text("We’ll scan for gateways on your network and connect automatically when we find one.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auto detect")
|
||||
.onAppear { self.triggerAutoConnect() }
|
||||
.onChange(of: self.gatewayController.gateways) { _, _ in
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAutoConnect() {
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
guard let candidate = self.autoCandidate() else { return }
|
||||
|
||||
self.connectingGatewayID = candidate.id
|
||||
Task {
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !preferred.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if !lastDiscovered.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if self.gatewayController.gateways.count == 1 {
|
||||
return self.gatewayController.gateways.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPortText: String = ""
|
||||
@State private var manualUseTLS: Bool = true
|
||||
@State private var manualToken: String = ""
|
||||
@State private var manualPassword: String = ""
|
||||
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Setup code") {
|
||||
Text("Use /pair in your bot to get a setup code.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button("Apply setup code") {
|
||||
self.applySetupCode()
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let setupStatusText, !setupStatusText.isEmpty {
|
||||
Text(setupStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualUseTLS)
|
||||
|
||||
TextField("Gateway token", text: self.$manualToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway password", text: self.$manualPassword)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Manual entry")
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
|
||||
if let port = self.manualPortValue(), !(1...65535).contains(port) {
|
||||
self.connectStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.manual.enabled")
|
||||
defaults.set(host, forKey: "gateway.manual.host")
|
||||
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
|
||||
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
|
||||
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty {
|
||||
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedToken.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "manual"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.manualToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.manualPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualPortValue() ?? 0,
|
||||
useTLS: self.manualUseTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.manualHost = ""
|
||||
self.manualPortText = ""
|
||||
self.manualUseTLS = true
|
||||
self.manualToken = ""
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return
|
||||
}
|
||||
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return
|
||||
}
|
||||
|
||||
self.manualHost = link.host
|
||||
self.manualPortText = String(link.port)
|
||||
self.manualUseTLS = link.tls
|
||||
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.manualToken = setupAuth.token
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.manualPassword = setupAuth.password
|
||||
}
|
||||
|
||||
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(
|
||||
appModel: self.appModel,
|
||||
instanceId: trimmedInstanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
|
||||
self.setupStatusText = "Setup code applied."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
connectingGatewayID: inout String?)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
connectStatusText = nil
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
secondaryLine: String?) -> some View
|
||||
{
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: gatewayConnectionStatusLines(
|
||||
appModel: appModel,
|
||||
gatewayController: gatewayController),
|
||||
secondaryLine: secondaryLine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.statusLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let secondaryLine, !secondaryLine.isEmpty {
|
||||
Text(secondaryLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")")
|
||||
return lines
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,6 @@ enum OnboardingStateStore {
|
||||
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
|
||||
@@ -17,10 +17,6 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
Self(rawValue: self.rawValue - 1)
|
||||
}
|
||||
|
||||
var next: Self? {
|
||||
Self(rawValue: self.rawValue + 1)
|
||||
}
|
||||
|
||||
/// Progress label for the manual setup flow (mode → connect → auth → success).
|
||||
var manualProgressTitle: String {
|
||||
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
@@ -22,9 +22,22 @@ enum OpenClawAppModelRegistry {
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
|
||||
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "BackgroundWake")
|
||||
private static var wakeRefreshTaskIdentifier: String {
|
||||
"\(appBundleIdentifier).bgrefresh"
|
||||
}
|
||||
|
||||
private static var appBundleIdentifier: String {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleId.isEmpty
|
||||
else {
|
||||
return "ai.openclawfoundation.app"
|
||||
}
|
||||
|
||||
return bundleId
|
||||
}
|
||||
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
@@ -92,6 +105,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
|
||||
func _test_wakeRefreshTaskIdentifier() -> String {
|
||||
Self.wakeRefreshTaskIdentifier
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
@@ -611,9 +628,21 @@ struct OpenClawApp: App {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
#if DEBUG
|
||||
if Self.screenshotModeEnabled {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
UserDefaults.standard.set(true, forKey: "gateway.onboardingComplete")
|
||||
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
|
||||
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
|
||||
appModel.enterScreenshotFixtureMode()
|
||||
}
|
||||
#endif
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -649,6 +678,14 @@ struct OpenClawApp: App {
|
||||
?? .system
|
||||
}
|
||||
|
||||
private static var screenshotModeEnabled: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-mode")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
|
||||
@@ -39,10 +39,6 @@ struct PushBuildConfig {
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
var usesRelay: Bool {
|
||||
self.transport == .relay
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -13,7 +13,7 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclaw.pushrelay"
|
||||
private static let service = "ai.openclawfoundation.app.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
@@ -71,11 +71,6 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearRegistrationState() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -7,7 +7,6 @@ extension RootTabs {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user