mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 15:32:40 +08:00
Compare commits
487 Commits
aknight/pl
...
fix/exec-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bc882853 | ||
|
|
9fa14ff61a | ||
|
|
760f86453e | ||
|
|
e08ef9f893 | ||
|
|
14b912261b | ||
|
|
9b9b058ebf | ||
|
|
1b7c1c2eb7 | ||
|
|
026123dc76 | ||
|
|
2920dc3282 | ||
|
|
ea56b135c8 | ||
|
|
32494c7ace | ||
|
|
43f2b61f3b | ||
|
|
0ec12df245 | ||
|
|
2592f8a51a | ||
|
|
fee8ab4764 | ||
|
|
b60f63150f | ||
|
|
391e492f56 | ||
|
|
086c629556 | ||
|
|
d96ac02dc6 | ||
|
|
c51661f1bf | ||
|
|
2f8ad67a5e | ||
|
|
e39249100e | ||
|
|
befe04f465 | ||
|
|
5e342c774d | ||
|
|
321e58c030 | ||
|
|
82316c2f45 | ||
|
|
a70dae40b7 | ||
|
|
3675c01410 | ||
|
|
cc32f277fe | ||
|
|
a409df6f9c | ||
|
|
264b37e9d2 | ||
|
|
3f7ef1be37 | ||
|
|
330fc9f7b9 | ||
|
|
3c06770a82 | ||
|
|
fc15c58715 | ||
|
|
2ce4a7483a | ||
|
|
fac091b39d | ||
|
|
89de454f82 | ||
|
|
de9c94cbbb | ||
|
|
5e915e1f89 | ||
|
|
dcb6b0dd6f | ||
|
|
961130c707 | ||
|
|
ef62076789 | ||
|
|
a1c2454b08 | ||
|
|
63b13ea837 | ||
|
|
c0b6183b7b | ||
|
|
0edd84f910 | ||
|
|
ea9065bc68 | ||
|
|
adc4d9fe02 | ||
|
|
75af913ba6 | ||
|
|
739e6cbbf8 | ||
|
|
8357260081 | ||
|
|
aeedfceb28 | ||
|
|
75b9e761b7 | ||
|
|
1cdc28605d | ||
|
|
037ee6de0a | ||
|
|
d6111ff72c | ||
|
|
ed2dfee7d7 | ||
|
|
af328b2b21 | ||
|
|
88c3bb5391 | ||
|
|
e9756f9e71 | ||
|
|
2e0dd66d39 | ||
|
|
1423487351 | ||
|
|
01d212bfa3 | ||
|
|
3d787b5181 | ||
|
|
89c90210fb | ||
|
|
65a20ca4c5 | ||
|
|
d5d9a8256d | ||
|
|
5dfbb9d1e0 | ||
|
|
3a32d24395 | ||
|
|
90fb2ee4e1 | ||
|
|
5d9daea2b0 | ||
|
|
2dc2d73b07 | ||
|
|
9122e762d8 | ||
|
|
769579bcf0 | ||
|
|
056e5b6b07 | ||
|
|
8fdb1b61db | ||
|
|
a2d7882100 | ||
|
|
e33760c9df | ||
|
|
392377e7e4 | ||
|
|
0a338147a5 | ||
|
|
013e33c6d3 | ||
|
|
dbd4c98b02 | ||
|
|
0529281430 | ||
|
|
284e514e19 | ||
|
|
066700bdd0 | ||
|
|
470a0f80b6 | ||
|
|
b31bf811cb | ||
|
|
1662b07810 | ||
|
|
cf31689a03 | ||
|
|
a07d92ff4f | ||
|
|
858fd2c5a2 | ||
|
|
7c4ab782cb | ||
|
|
5cafe4b0cf | ||
|
|
c4cac33af6 | ||
|
|
965d1fff3f | ||
|
|
23f94bfa78 | ||
|
|
a0ed4273ee | ||
|
|
bfbf25e234 | ||
|
|
8c366bfefd | ||
|
|
b4bc1f20c9 | ||
|
|
c782fa98aa | ||
|
|
81e1ec467c | ||
|
|
10113b2c9f | ||
|
|
f8df80646b | ||
|
|
541f7ffc65 | ||
|
|
43f134ff55 | ||
|
|
780f83bcfb | ||
|
|
37714f185f | ||
|
|
9d1ba36f6b | ||
|
|
908a71ab57 | ||
|
|
253180a265 | ||
|
|
3dce88e2b3 | ||
|
|
025db6cf9e | ||
|
|
1ed8592467 | ||
|
|
dc9ad35bda | ||
|
|
d6d7a4c4b8 | ||
|
|
4e24dcf396 | ||
|
|
ab3d2b44ac | ||
|
|
e5f3df6538 | ||
|
|
5d48a2ec54 | ||
|
|
b49395ddb1 | ||
|
|
105a30b5a5 | ||
|
|
4d636335db | ||
|
|
1585ec54f1 | ||
|
|
f257c0609d | ||
|
|
d63389ccf6 | ||
|
|
420a0e6fce | ||
|
|
96c6f8022c | ||
|
|
03ba09bfa8 | ||
|
|
8d5fe80303 | ||
|
|
24fc2e9a88 | ||
|
|
a1181023ba | ||
|
|
be43c55398 | ||
|
|
b8434386b8 | ||
|
|
92264fbb8f | ||
|
|
7c8ca26364 | ||
|
|
96e49705a6 | ||
|
|
e3a496a29a | ||
|
|
8f2882f94a | ||
|
|
25090056dc | ||
|
|
e8a31ddbce | ||
|
|
b335381247 | ||
|
|
e90fb67641 | ||
|
|
1fc4342a02 | ||
|
|
9fbc8a74ef | ||
|
|
734f2aa009 | ||
|
|
50e7a546a1 | ||
|
|
c51933dc23 | ||
|
|
31941f3e92 | ||
|
|
305a44388b | ||
|
|
65adb13581 | ||
|
|
0276cbbce2 | ||
|
|
3ff0c29f9d | ||
|
|
daa382611f | ||
|
|
9bf681d663 | ||
|
|
feb3694243 | ||
|
|
f3d92936b5 | ||
|
|
c854e4e93f | ||
|
|
405896a4a3 | ||
|
|
a9d40b64bc | ||
|
|
e0d7c4c548 | ||
|
|
1f89d6d7f7 | ||
|
|
17aa9d9967 | ||
|
|
58628604ab | ||
|
|
80e031cc1d | ||
|
|
92b283da84 | ||
|
|
9616035a91 | ||
|
|
a9c7397cde | ||
|
|
cb84041cab | ||
|
|
5d892e484d | ||
|
|
8c8eb86fff | ||
|
|
5636c6044b | ||
|
|
0a9b1526ac | ||
|
|
604d607311 | ||
|
|
303e7781c1 | ||
|
|
9a54e5b292 | ||
|
|
de60f42767 | ||
|
|
c6aa355b5c | ||
|
|
f00f42abf7 | ||
|
|
af7797b0ad | ||
|
|
80805ad7a5 | ||
|
|
86ea382121 | ||
|
|
e1ecfa5200 | ||
|
|
7f6a93eb8e | ||
|
|
aa79ab1403 | ||
|
|
a87aed4108 | ||
|
|
69c4d1aa85 | ||
|
|
7c90351ff3 | ||
|
|
3a7cdaf32c | ||
|
|
ae22f485ec | ||
|
|
dab145ef76 | ||
|
|
336494c863 | ||
|
|
7588bd7b75 | ||
|
|
37ac0f0dd2 | ||
|
|
345ad9862d | ||
|
|
206552c697 | ||
|
|
451ae8c678 | ||
|
|
a29edce409 | ||
|
|
e3058efa10 | ||
|
|
b3b5b08e67 | ||
|
|
71ef6b2312 | ||
|
|
a6390b2b90 | ||
|
|
e2e678326e | ||
|
|
4ec006da66 | ||
|
|
8fe181c2b0 | ||
|
|
e66aa357f8 | ||
|
|
8b78ae2855 | ||
|
|
b10fedb7de | ||
|
|
008d101b16 | ||
|
|
28b374a8a7 | ||
|
|
ae6bea1771 | ||
|
|
f7d6a059a4 | ||
|
|
f6da93db0f | ||
|
|
905c9759a7 | ||
|
|
9c85b812fe | ||
|
|
83cfb6112c | ||
|
|
8744e86e67 | ||
|
|
da63854f58 | ||
|
|
a2b8f67395 | ||
|
|
d3781cc4b8 | ||
|
|
3895c9341b | ||
|
|
7626ca38b3 | ||
|
|
49fac864d4 | ||
|
|
4e5b788234 | ||
|
|
c149d217da | ||
|
|
3288291a08 | ||
|
|
afa1045238 | ||
|
|
dbc07ad84d | ||
|
|
6b11bd97d9 | ||
|
|
0a2ca1f7ac | ||
|
|
73930764e6 | ||
|
|
a4eb49a176 | ||
|
|
db21588636 | ||
|
|
88b64e4b86 | ||
|
|
fcb4c5d041 | ||
|
|
49869c2e41 | ||
|
|
a0fedcfb7e | ||
|
|
1b7a6a3138 | ||
|
|
f236217d5b | ||
|
|
927d0aefeb | ||
|
|
362c26a986 | ||
|
|
96e27c6ea8 | ||
|
|
90cf265f29 | ||
|
|
f40071cc0f | ||
|
|
f378de9d5b | ||
|
|
77f4e45c35 | ||
|
|
d48dcc664b | ||
|
|
ca360d3d90 | ||
|
|
54d24cd956 | ||
|
|
3939da7a09 | ||
|
|
a641c0d560 | ||
|
|
482e6cb5cb | ||
|
|
35bafea757 | ||
|
|
5dc6e0ea77 | ||
|
|
b9a7bf83a4 | ||
|
|
5c5a8a49d7 | ||
|
|
35be382e56 | ||
|
|
bdf75474b9 | ||
|
|
f13a10c798 | ||
|
|
2ba9d6eabe | ||
|
|
6f17c4cc6d | ||
|
|
e6f3912347 | ||
|
|
c6d9977902 | ||
|
|
c67bb1c5aa | ||
|
|
b8b2f5d98f | ||
|
|
6456790287 | ||
|
|
f247ef320a | ||
|
|
b08555ef55 | ||
|
|
7fe287b0d3 | ||
|
|
f77a74dec7 | ||
|
|
0c1f963532 | ||
|
|
530658dc29 | ||
|
|
8cc5b371f1 | ||
|
|
afa97a4b10 | ||
|
|
d9482063a9 | ||
|
|
89eb493d1d | ||
|
|
387b5337ec | ||
|
|
03a71f3b46 | ||
|
|
2220f43f69 | ||
|
|
9ce4c92736 | ||
|
|
8625b8a92b | ||
|
|
5571c786d3 | ||
|
|
b9d254f2b0 | ||
|
|
9f675920bf | ||
|
|
78b33b86d3 | ||
|
|
3ca3b97a21 | ||
|
|
ceb69221ec | ||
|
|
729de383bc | ||
|
|
d1026a3a1a | ||
|
|
2fbce1c036 | ||
|
|
741bac9fdf | ||
|
|
8cf0d7dd33 | ||
|
|
25e2017062 | ||
|
|
cb301cd16f | ||
|
|
d8e6ee04d0 | ||
|
|
493e418fa5 | ||
|
|
4bde68ed38 | ||
|
|
a289146344 | ||
|
|
95093303c8 | ||
|
|
607b2e9663 | ||
|
|
aed6f0a14e | ||
|
|
e20edd753b | ||
|
|
a89e65c167 | ||
|
|
f0afbd7e32 | ||
|
|
d9a38130b1 | ||
|
|
f2eca94391 | ||
|
|
4e9dc6b5d5 | ||
|
|
1711d0123c | ||
|
|
d3f7f7d1fc | ||
|
|
7cc21ef59d | ||
|
|
f9ebb8d91b | ||
|
|
783d5c19dd | ||
|
|
15a0609a6b | ||
|
|
44ae2fd936 | ||
|
|
8e76feb482 | ||
|
|
f1c6057cd7 | ||
|
|
c57fee8239 | ||
|
|
23b4f33195 | ||
|
|
078044a2cf | ||
|
|
8ad231c241 | ||
|
|
04994f1046 | ||
|
|
9b4c5822eb | ||
|
|
b39e905b69 | ||
|
|
df6c71736c | ||
|
|
2b50bbf152 | ||
|
|
1bd85e3cc3 | ||
|
|
984efdb0b6 | ||
|
|
abdd81db11 | ||
|
|
3cc05d590c | ||
|
|
8246e8dace | ||
|
|
5df513c895 | ||
|
|
1529958067 | ||
|
|
7d3bc4d944 | ||
|
|
5fbb5f75ed | ||
|
|
4506d8bad6 | ||
|
|
7668ef2d35 | ||
|
|
8e4213b1c4 | ||
|
|
a0ab5c00a8 | ||
|
|
dc09324ec2 | ||
|
|
3be0fe722a | ||
|
|
6be98022da | ||
|
|
cc89e155c3 | ||
|
|
0111afe9e2 | ||
|
|
674b4f3372 | ||
|
|
4db829646a | ||
|
|
e046dbb52d | ||
|
|
a964132d80 | ||
|
|
0374892fd8 | ||
|
|
a2b3aab7b0 | ||
|
|
2ece2945ae | ||
|
|
f05fd56d66 | ||
|
|
a9a75b2b77 | ||
|
|
e29381a172 | ||
|
|
62456d65eb | ||
|
|
696c624008 | ||
|
|
b3b8b289dd | ||
|
|
692c5e34f0 | ||
|
|
536c8a840b | ||
|
|
99551c499b | ||
|
|
2c3519c1d7 | ||
|
|
e114001cca | ||
|
|
3ff59df960 | ||
|
|
a594d2ce73 | ||
|
|
c92f366c14 | ||
|
|
2b92706dcf | ||
|
|
a9be81d510 | ||
|
|
97a015bace | ||
|
|
93c7ec645a | ||
|
|
920bd04e19 | ||
|
|
5ae53cf9fb | ||
|
|
1168ac2fcd | ||
|
|
112a0ddaf8 | ||
|
|
c3ab1feb61 | ||
|
|
0bd2aa8ee0 | ||
|
|
7d4d8a7f3d | ||
|
|
e9be15ff19 | ||
|
|
05580342f7 | ||
|
|
17dc9902f2 | ||
|
|
540ec53f99 | ||
|
|
a182811070 | ||
|
|
4e2f0157c7 | ||
|
|
0f64e3c052 | ||
|
|
65388233e2 | ||
|
|
044df2516e | ||
|
|
190ca52882 | ||
|
|
ad304e790d | ||
|
|
e913e0739d | ||
|
|
8e24695a8d | ||
|
|
b0ecf6e1e7 | ||
|
|
dc5c2f6360 | ||
|
|
2405d029d4 | ||
|
|
4e96ca0d12 | ||
|
|
84ccba6b32 | ||
|
|
d17bb9c3e9 | ||
|
|
2f9107f672 | ||
|
|
6328c8637b | ||
|
|
3053cbc8a5 | ||
|
|
eb9318e953 | ||
|
|
f80f4a8b95 | ||
|
|
034629404d | ||
|
|
ef67ffd697 | ||
|
|
fe524d2a46 | ||
|
|
6c8dcc9d35 | ||
|
|
34ab295734 | ||
|
|
a5139a8c5c | ||
|
|
7f99824164 | ||
|
|
cbaeaa8856 | ||
|
|
a6cac347b6 | ||
|
|
4d0aec8095 | ||
|
|
fa51a624c0 | ||
|
|
075e328c62 | ||
|
|
0d21d489ab | ||
|
|
d8f1000600 | ||
|
|
227b4c81ed | ||
|
|
d28d6c2399 | ||
|
|
d09f728208 | ||
|
|
9e8ab083dd | ||
|
|
6eb72a830e | ||
|
|
6b1eef9959 | ||
|
|
14e448e0e1 | ||
|
|
aa3797c8d0 | ||
|
|
93ad397725 | ||
|
|
cf1b6fef44 | ||
|
|
d990115d19 | ||
|
|
8a75c4dd5f | ||
|
|
efd3172662 | ||
|
|
8afc1f770b | ||
|
|
77012f9807 | ||
|
|
2732f58215 | ||
|
|
3eeccbe782 | ||
|
|
2b75806197 | ||
|
|
adb9abe721 | ||
|
|
2a6554ac12 | ||
|
|
4113982fa8 | ||
|
|
2800ce4e28 | ||
|
|
45a93b8450 | ||
|
|
708c1b31e0 | ||
|
|
3fc1284fe6 | ||
|
|
1a075c375c | ||
|
|
95f314e822 | ||
|
|
efe3cbd695 | ||
|
|
79fac9fda9 | ||
|
|
ef6dc8f7e5 | ||
|
|
f31306eb4e | ||
|
|
db24112617 | ||
|
|
4fd19adf25 | ||
|
|
62dcc9bc3b | ||
|
|
bda4404f69 | ||
|
|
30925601ae | ||
|
|
a2675756b8 | ||
|
|
095a44c8de | ||
|
|
29eba5aaef | ||
|
|
b99812b3b1 | ||
|
|
4f7d1f4977 | ||
|
|
c310f8cfa4 | ||
|
|
08442c4b38 | ||
|
|
fe7b78b05f | ||
|
|
dd89898133 | ||
|
|
851b65c060 | ||
|
|
75c6a8fff5 | ||
|
|
f9fc380e90 | ||
|
|
8ef73be8e8 | ||
|
|
afadf1f7da | ||
|
|
29185aed68 | ||
|
|
3e5ca880bf | ||
|
|
a09e1b9aa0 | ||
|
|
11959ad100 | ||
|
|
e37b0f8cd3 | ||
|
|
dbb58341b5 | ||
|
|
790dfb66a8 | ||
|
|
4cb94cc2cf | ||
|
|
298a0cd55f | ||
|
|
c578608b78 | ||
|
|
8c0767ffa4 | ||
|
|
a0714a3d68 | ||
|
|
f719813a7e | ||
|
|
11a3903ede | ||
|
|
1f6ae32cab | ||
|
|
880425b03c | ||
|
|
4c736df975 | ||
|
|
6c85b90469 | ||
|
|
ef41560059 | ||
|
|
88b21fc30b | ||
|
|
4b6182ee2a | ||
|
|
d43bc3760e | ||
|
|
8cd0c11227 |
@@ -4,6 +4,14 @@ set -euo pipefail
|
||||
repo="openclaw/openclaw"
|
||||
months="12"
|
||||
include_global="0"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -z "$repo_root" ]; then
|
||||
repo_root="$(cd "$script_dir/../../../.." && pwd)"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$repo_root/scripts/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
|
||||
@@ -18,6 +26,10 @@ need() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
date_utc_relative_months() {
|
||||
local count="$1"
|
||||
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
|
||||
@@ -131,7 +143,8 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
need gh
|
||||
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
|
||||
export OPENCLAW_GH_BIN
|
||||
need jq
|
||||
|
||||
since_ts=$(date_utc_relative_months "$months")
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Usage: node secret-scanning.mjs <command> [options]
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
|
||||
}
|
||||
|
||||
function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
if (proc.status !== 0 && !allowFailure) {
|
||||
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const runId = process.argv[2];
|
||||
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
|
||||
@@ -15,8 +16,9 @@ if (!runId) {
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync("gh", args, {
|
||||
return execFileSync(resolvePlainGhBin(), args, {
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
@@ -32,14 +34,15 @@ function githubRestJson(pathSuffix) {
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
...plainGhEnv(),
|
||||
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
|
||||
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Docs bug report
|
||||
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
|
||||
title: "[Docs Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
- docs
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Report a documentation defect with concrete evidence from current docs behavior/content.
|
||||
Please only report one documentation defect per submission.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is wrong in the docs.
|
||||
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: doc_paths
|
||||
attributes:
|
||||
label: Affected docs path(s) or URL(s)
|
||||
description: Repo-relative docs file path(s) or published docs URL(s).
|
||||
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce / verify
|
||||
description: Minimal steps to observe the docs defect in the current docs.
|
||||
placeholder: |
|
||||
1. Open docs/gateway/config-channels.md
|
||||
2. Go to the WhatsApp example block
|
||||
3. Observe duplicate top-level key definitions
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected docs behavior/content
|
||||
description: What the docs should say/show instead.
|
||||
placeholder: The example should use a single merged top-level object with no duplicate keys.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual docs behavior/content
|
||||
description: What the docs currently say/show.
|
||||
placeholder: The snippet defines the same top-level key twice in one object.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: Who is affected and practical consequence.
|
||||
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Links/snippets/screenshots proving the docs defect.
|
||||
placeholder: Include exact file links and line ranges.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Optional context, related issues/PRs, or constraints.
|
||||
@@ -22,7 +22,7 @@ paths:
|
||||
- src/plugins/memory-*.ts
|
||||
- src/gateway/server-startup-memory.ts
|
||||
- src/commands/doctor-memory-search.ts
|
||||
- src/commands/doctor-cron-dreaming-payload-migration.ts
|
||||
- src/commands/doctor/cron/dreaming-payload-migration.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -19,7 +19,6 @@ paths:
|
||||
- src/plugins/bundled-compat.ts
|
||||
- src/plugins/bundled-dir.ts
|
||||
- src/plugins/bundled-plugin-metadata.ts
|
||||
- src/plugins/bundled-public-surface-runtime-root.ts
|
||||
- src/plugins/plugin-sdk-dist-alias.ts
|
||||
- src/plugins/captured-registration.ts
|
||||
- src/plugins/config-activation-shared.ts
|
||||
@@ -46,7 +45,6 @@ paths:
|
||||
- src/plugins/runtime-state.ts
|
||||
- src/plugins/runtime.ts
|
||||
- src/plugins/sdk-alias.ts
|
||||
- src/plugins/source-loader.ts
|
||||
- src/plugins/types.ts
|
||||
- src/plugins/validation-diagnostics.ts
|
||||
- src/plugins/web-provider-public-artifacts*.ts
|
||||
|
||||
@@ -51,7 +51,6 @@ paths:
|
||||
- src/plugins/runtime
|
||||
- src/plugins/runtime-state.ts
|
||||
- src/plugins/runtime.ts
|
||||
- src/plugins/source-loader.ts
|
||||
- src/plugins/update.ts
|
||||
- src/plugins/validation-diagnostics.ts
|
||||
- src/plugin-sdk/*entry*.ts
|
||||
|
||||
17
.github/labeler.yml
vendored
17
.github/labeler.yml
vendored
@@ -41,12 +41,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-meet/**"
|
||||
- "docs/plugins/google-meet.md"
|
||||
"plugin: meeting-notes":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/meeting-notes/**"
|
||||
- "docs/plugins/meeting-notes.md"
|
||||
- "src/meeting-notes/**"
|
||||
"plugin: workboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -109,6 +103,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qqbot/**"
|
||||
- "docs/channels/qqbot.md"
|
||||
"channel: raft":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/raft/**"
|
||||
- "docs/channels/raft.md"
|
||||
"channel: qa-channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -252,12 +251,12 @@
|
||||
- "src/agents/sandbox*.ts"
|
||||
- "src/commands/sandbox*.ts"
|
||||
- "src/cli/sandbox-cli.ts"
|
||||
- "src/docker-setup.test.ts"
|
||||
- "src/docker-setup.e2e.test.ts"
|
||||
- "src/config/**/*sandbox*"
|
||||
- "docs/cli/sandbox.md"
|
||||
- "docs/gateway/sandbox*.md"
|
||||
- "docs/install/docker.md"
|
||||
- "docs/multi-agent-sandbox-tools.md"
|
||||
- "docs/tools/multi-agent-sandbox-tools.md"
|
||||
|
||||
"agents":
|
||||
- changed-files:
|
||||
@@ -270,7 +269,7 @@
|
||||
- ".github/workflows/opengrep-*.yml"
|
||||
- ".semgrepignore"
|
||||
- "docs/cli/security.md"
|
||||
- "docs/gateway/security.md"
|
||||
- "docs/gateway/security/**"
|
||||
- "security/**"
|
||||
|
||||
"extensions: admin-http-rpc":
|
||||
|
||||
139
.github/workflows/ci.yml
vendored
139
.github/workflows/ci.yml
vendored
@@ -41,11 +41,32 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Keep the canonical main queue quiet long enough for a follow-up push to
|
||||
# cancel this run before it registers the Blacksmith matrix.
|
||||
runner-admission:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
|
||||
steps:
|
||||
- name: Debounce canonical main pushes
|
||||
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
|
||||
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
|
||||
- name: Admit non-main CI runs immediately
|
||||
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
|
||||
run: echo "No canonical main debounce required"
|
||||
|
||||
# Preflight: establish routing truth and job matrices once, then let real
|
||||
# work fan out from a single source of truth.
|
||||
preflight:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [runner-admission]
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -197,7 +218,7 @@ jobs:
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import {
|
||||
createNodeTestShards,
|
||||
createNodeTestShardBundles,
|
||||
} from "./scripts/lib/ci-node-test-plan.mjs";
|
||||
import {
|
||||
createChannelContractTestShards,
|
||||
@@ -272,18 +293,23 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
|
||||
const nodeTestShards = runNodeFull
|
||||
? createNodeTestShards({
|
||||
? createNodeTestShardBundles({
|
||||
includeReleaseOnlyPluginShards: false,
|
||||
compact: compactPullRequest,
|
||||
}).map((shard) => ({
|
||||
check_name: shard.checkName,
|
||||
runtime: "node",
|
||||
task: "test-shard",
|
||||
shard_name: shard.shardName,
|
||||
groups: shard.groups,
|
||||
configs: shard.configs,
|
||||
env: shard.env,
|
||||
includePatterns: shard.includePatterns,
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
timeout_minutes: shard.timeoutMinutes,
|
||||
}))
|
||||
: [];
|
||||
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
|
||||
@@ -320,7 +346,14 @@ jobs:
|
||||
run_checks_windows: runWindows,
|
||||
checks_windows_matrix: createMatrix(
|
||||
runWindows
|
||||
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
|
||||
? [
|
||||
{
|
||||
check_name: "checks-windows-node-test",
|
||||
runtime: "node",
|
||||
task: "test",
|
||||
runner: "blacksmith-8vcpu-windows-2025",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
run_macos_node: runMacos,
|
||||
@@ -354,6 +387,7 @@ jobs:
|
||||
security-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [runner-admission]
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -558,7 +592,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -819,6 +853,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -908,6 +943,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -988,6 +1024,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1136,10 +1173,11 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1198,7 +1236,9 @@ jobs:
|
||||
- name: Run Node test shard
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
||||
@@ -1212,28 +1252,55 @@ jobs:
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
|
||||
if (!Array.isArray(configs) || configs.length === 0) {
|
||||
console.error("Missing node test shard configs");
|
||||
process.exit(1);
|
||||
}
|
||||
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
|
||||
const childEnv = { ...process.env };
|
||||
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
|
||||
const includeFile = join(
|
||||
process.env.RUNNER_TEMP ?? ".",
|
||||
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
|
||||
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
|
||||
const plans = Array.isArray(groups) && groups.length > 0
|
||||
? groups
|
||||
: [{
|
||||
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
|
||||
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
|
||||
includePatterns: JSON.parse(
|
||||
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
|
||||
),
|
||||
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
|
||||
}];
|
||||
for (const plan of plans) {
|
||||
const configs = plan.configs;
|
||||
if (!Array.isArray(configs) || configs.length === 0) {
|
||||
console.error("Missing node test shard configs");
|
||||
process.exit(1);
|
||||
}
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
|
||||
};
|
||||
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
|
||||
for (const [key, value] of Object.entries(plan.env)) {
|
||||
if (typeof value === "string") {
|
||||
childEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
|
||||
const includeFile = join(
|
||||
process.env.RUNNER_TEMP ?? ".",
|
||||
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
|
||||
);
|
||||
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
|
||||
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
|
||||
} else {
|
||||
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
|
||||
}
|
||||
const result = spawnSync(
|
||||
"pnpm",
|
||||
["exec", "node", "scripts/test-projects.mjs", ...configs],
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
|
||||
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
|
||||
}
|
||||
|
||||
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1248,6 +1315,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-guards
|
||||
@@ -1264,7 +1332,7 @@ jobs:
|
||||
runner: blacksmith-16vcpu-ubuntu-2404
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1385,30 +1453,39 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-additional-boundaries-a
|
||||
group: boundaries
|
||||
boundary_shard: 1/4
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-bundled
|
||||
group: extension-bundled
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-package-boundary
|
||||
group: extension-package-boundary
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-runtime-topology-architecture
|
||||
group: runtime-topology-architecture
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1751,7 +1828,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_windows == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-windows-2025') || 'windows-2025') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -1763,6 +1840,7 @@ jobs:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2092,6 +2170,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -96,7 +96,7 @@ on:
|
||||
- "src/auto-reply/reply/post-compaction-context.ts"
|
||||
- "src/auto-reply/reply/queue/**"
|
||||
- "src/auto-reply/reply/startup-context.ts"
|
||||
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
|
||||
- "src/commands/doctor/cron/dreaming-payload-migration.ts"
|
||||
- "src/commands/doctor-memory-search.ts"
|
||||
- "src/commands/doctor-session-*.ts"
|
||||
- "src/commands/session-store-targets.ts"
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
gateway=true
|
||||
;;
|
||||
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
memory=true
|
||||
;;
|
||||
src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts)
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
src/model-catalog/*|src/plugins/*provider*.ts|src/plugins/capability-provider-runtime.ts|src/plugins/compaction-provider.ts|src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts|src/plugins/migration-provider-runtime.ts|src/plugins/synthetic-auth.runtime.ts|src/plugins/web-fetch-providers*.ts|src/plugins/web-search-providers*.ts)
|
||||
provider=true
|
||||
;;
|
||||
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/source-loader.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
|
||||
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
|
||||
plugin=true
|
||||
;;
|
||||
packages/plugin-package-contract/*|packages/plugin-sdk/*)
|
||||
|
||||
@@ -244,6 +244,11 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
|
||||
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
376
.github/workflows/qa-profile-evidence.yml
vendored
Normal file
376
.github/workflows/qa-profile-evidence.yml
vendored
Normal file
@@ -0,0 +1,376 @@
|
||||
name: QA Profile Evidence
|
||||
|
||||
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA to run
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
default: release
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA to run
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the reusable workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
artifact_name:
|
||||
description: Uploaded QA profile evidence artifact name
|
||||
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id that produced the evidence
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
|
||||
qa_exit_code:
|
||||
description: Exit code from the QA profile run; non-zero evidence is still uploaded
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
|
||||
qa_passed:
|
||||
description: Whether the QA profile command exited successfully
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
|
||||
target_sha:
|
||||
description: Resolved OpenClaw SHA that produced the evidence
|
||||
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
|
||||
trusted_reason:
|
||||
description: Trust reason accepted before the secret-bearing QA job
|
||||
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
|
||||
qa_evidence_path:
|
||||
description: Path to qa-evidence.json inside the uploaded artifact
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
|
||||
jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: context.actor,
|
||||
});
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_selected_ref:
|
||||
name: Validate selected ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_revision: ${{ steps.validate.outputs.selected_revision }}
|
||||
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
trusted_reason=""
|
||||
|
||||
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
|
||||
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
|
||||
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
|
||||
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
|
||||
trusted_reason="release-branch-head"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Target"
|
||||
echo
|
||||
echo "- Requested ref: \`${INPUT_REF}\`"
|
||||
echo "- Resolved SHA: \`$selected_revision\`"
|
||||
echo "- Trust reason: \`$trusted_reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
run_qa_profile:
|
||||
name: Generate QA profile evidence
|
||||
needs: validate_selected_ref
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
|
||||
qa_profile: ${{ steps.profile.outputs.profile }}
|
||||
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
|
||||
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
|
||||
target_sha: ${{ steps.evidence.outputs.target_sha }}
|
||||
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
|
||||
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate QA profile input
|
||||
id: profile
|
||||
env:
|
||||
QA_PROFILE: ${{ inputs.qa_profile }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
|
||||
|
||||
const requested = process.env.QA_PROFILE?.trim() ?? "";
|
||||
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
|
||||
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
|
||||
}
|
||||
|
||||
const taxonomy = readQaScorecardTaxonomyReport([]);
|
||||
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
|
||||
if (!profile) {
|
||||
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
|
||||
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
|
||||
}
|
||||
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
|
||||
NODE
|
||||
|
||||
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run QA profile
|
||||
id: run_profile
|
||||
env:
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
qa_exit_code=0
|
||||
pnpm openclaw qa run \
|
||||
--repo-root . \
|
||||
--qa-profile "${QA_PROFILE}" \
|
||||
--output-dir "${output_dir}" || qa_exit_code=$?
|
||||
|
||||
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate QA profile evidence
|
||||
id: evidence
|
||||
if: always()
|
||||
env:
|
||||
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
REQUESTED_REF: ${{ inputs.ref }}
|
||||
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const outputDir = process.env.OUTPUT_DIR;
|
||||
if (!outputDir) {
|
||||
throw new Error("OUTPUT_DIR is required");
|
||||
}
|
||||
if (!process.env.QA_EXIT_CODE) {
|
||||
throw new Error("QA_EXIT_CODE is required");
|
||||
}
|
||||
|
||||
const evidencePath = path.join(outputDir, "qa-evidence.json");
|
||||
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (payload.profile !== process.env.QA_PROFILE) {
|
||||
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
|
||||
}
|
||||
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
|
||||
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
|
||||
}
|
||||
if (payload.scorecard.categoryReports.length === 0) {
|
||||
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
artifactName: process.env.ARTIFACT_NAME,
|
||||
generatedAt: new Date().toISOString(),
|
||||
qaProfile: process.env.QA_PROFILE,
|
||||
qaExitCode: Number(process.env.QA_EXIT_CODE),
|
||||
qaPassed: process.env.QA_EXIT_CODE === "0",
|
||||
requestedRef: process.env.REQUESTED_REF,
|
||||
targetSha: process.env.TARGET_SHA,
|
||||
trustedReason: process.env.TRUSTED_REASON,
|
||||
evidenceMode: payload.evidenceMode,
|
||||
qaEvidencePath: "qa-evidence.json",
|
||||
scorecard: {
|
||||
categories: payload.scorecard.categories,
|
||||
features: payload.scorecard.features,
|
||||
categoryReports: payload.scorecard.categoryReports.length,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, "qa-profile-evidence-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$QA_EXIT_CODE" == "0" ]]; then
|
||||
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
|
||||
fi
|
||||
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### QA profile evidence"
|
||||
echo
|
||||
echo "- Artifact: \`${ARTIFACT_NAME}\`"
|
||||
echo "- QA profile: \`${QA_PROFILE}\`"
|
||||
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
|
||||
echo "- Target SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
|
||||
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload QA profile evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
path: ${{ steps.run_profile.outputs.output_dir }}
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Fail if configured QA gate failed
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || inputs.fail_on_qa_failure)
|
||||
env:
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
|
||||
echo "QA profile did not report an exit code." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$QA_EXIT_CODE" != "0" ]]; then
|
||||
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
|
||||
exit "$QA_EXIT_CODE"
|
||||
fi
|
||||
4
.github/workflows/real-behavior-proof.yml
vendored
4
.github/workflows/real-behavior-proof.yml
vendored
@@ -24,7 +24,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
# Old PR events can carry a stale base SHA that predates current
|
||||
# trusted checker scripts. Use the workflow revision instead.
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
id: app-token
|
||||
|
||||
42
.github/workflows/tui-pty.yml
vendored
42
.github/workflows/tui-pty.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: TUI PTY
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/tui/**"
|
||||
- "scripts/dev/tui-pty-test-watch.ts"
|
||||
- "scripts/test-projects.test-support.mjs"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "test/scripts/test-projects.test.ts"
|
||||
- "test/vitest/vitest.test-shards.mjs"
|
||||
- "test/vitest/vitest.tui-pty.config.ts"
|
||||
- ".github/workflows/tui-pty.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
tui-pty:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run TUI PTY tests
|
||||
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
4
.github/workflows/windows-testbox-probe.yml
vendored
4
.github/workflows/windows-testbox-probe.yml
vendored
@@ -297,6 +297,10 @@ jobs:
|
||||
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
|
||||
run: |
|
||||
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
|
||||
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
|
||||
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
|
||||
exit 1
|
||||
}
|
||||
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
|
||||
exit 1
|
||||
}
|
||||
|
||||
25
.github/workflows/workflow-sanity.yml
vendored
25
.github/workflows/workflow-sanity.yml
vendored
@@ -129,11 +129,28 @@ jobs:
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
|
||||
fetch_base_ref() {
|
||||
local ref="$1"
|
||||
local target="$2"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
"+${ref}:${target}" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::trusted base fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
fetch_base_ref "$BASE_SHA" "refs/remotes/origin/security-base" ||
|
||||
fetch_base_ref "refs/heads/${BASE_REF}" "refs/remotes/origin/${BASE_REF}"
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
||||
|
||||
@@ -8,6 +8,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Repo: `https://github.com/openclaw/openclaw`
|
||||
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
|
||||
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
|
||||
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
|
||||
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
|
||||
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
|
||||
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".VoiceE2eReceiver"
|
||||
android:exported="true">
|
||||
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import ai.openclaw.app.node.asStringOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
data class GatewayExecApprovalSummary(
|
||||
val id: String,
|
||||
val commandText: String,
|
||||
val commandPreview: String?,
|
||||
val allowedDecisions: List<String>,
|
||||
val host: String?,
|
||||
val nodeId: String?,
|
||||
val agentId: String?,
|
||||
val createdAtMs: Long?,
|
||||
val expiresAtMs: Long?,
|
||||
val resolvingDecision: String? = null,
|
||||
val errorText: String? = null,
|
||||
)
|
||||
|
||||
internal fun parseGatewayExecApprovalListPayload(
|
||||
payloadJson: String,
|
||||
json: Json,
|
||||
): List<GatewayExecApprovalSummary> =
|
||||
try {
|
||||
(json.parseToJsonElement(payloadJson) as? JsonArray)
|
||||
?.mapNotNull(::parseGatewayExecApprovalListEntry)
|
||||
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
|
||||
.orEmpty()
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
|
||||
val obj = item.asObjectOrNull() ?: return null
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
val request = obj["request"].asObjectOrNull()
|
||||
val commandText = gatewayExecApprovalListCommandText(obj, request)
|
||||
return GatewayExecApprovalSummary(
|
||||
id = id,
|
||||
commandText = commandText,
|
||||
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
|
||||
allowedDecisions = emptyList(),
|
||||
host =
|
||||
request
|
||||
?.get("host")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
nodeId =
|
||||
request
|
||||
?.get("nodeId")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
agentId =
|
||||
request
|
||||
?.get("agentId")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
createdAtMs = obj.long("createdAtMs"),
|
||||
expiresAtMs = obj.long("expiresAtMs"),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseGatewayExecApprovalDetail(
|
||||
obj: JsonObject,
|
||||
createdAtMs: Long?,
|
||||
): GatewayExecApprovalSummary? {
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
return GatewayExecApprovalSummary(
|
||||
id = id,
|
||||
commandText =
|
||||
obj["commandText"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Command request",
|
||||
commandPreview =
|
||||
obj["commandPreview"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
|
||||
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
createdAtMs = createdAtMs,
|
||||
expiresAtMs = obj.long("expiresAtMs"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
|
||||
obj["commandText"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: request
|
||||
?.get("command")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Command request"
|
||||
|
||||
private fun gatewayExecApprovalListCommandPreview(
|
||||
obj: JsonObject,
|
||||
request: JsonObject?,
|
||||
commandText: String,
|
||||
): String? {
|
||||
val preview =
|
||||
obj["commandPreview"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: request
|
||||
?.get("commandPreview")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return preview?.takeIf { it != commandText }
|
||||
}
|
||||
|
||||
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
|
||||
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
|
||||
if (explicit.isNotEmpty()) return explicit
|
||||
val allowed =
|
||||
if (request
|
||||
?.get("ask")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.lowercase() == "always"
|
||||
) {
|
||||
listOf("allow-once", "deny")
|
||||
} else {
|
||||
listOf("allow-once", "allow-always", "deny")
|
||||
}
|
||||
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
|
||||
return allowed.filterNot { it == "allow-always" && it in unavailable }
|
||||
}
|
||||
|
||||
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
|
||||
items
|
||||
?.mapNotNull { item ->
|
||||
when (item.asStringOrNull()?.trim()) {
|
||||
"allow-once" -> "allow-once"
|
||||
"allow-always" -> "allow-always"
|
||||
"deny" -> "deny"
|
||||
else -> null
|
||||
}
|
||||
}?.distinct()
|
||||
.orEmpty()
|
||||
|
||||
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
|
||||
@@ -204,6 +204,9 @@ class MainViewModel(
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
|
||||
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
|
||||
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
@@ -537,6 +540,17 @@ class MainViewModel(
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
ensureRuntime().refreshExecApprovals()
|
||||
}
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
ensureRuntime().resolveExecApproval(id = id, decision = decision)
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.A2UIHandler
|
||||
import ai.openclaw.app.node.CalendarHandler
|
||||
@@ -73,7 +74,9 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -399,6 +402,15 @@ class NodeRuntime(
|
||||
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
|
||||
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
|
||||
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
|
||||
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
|
||||
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
|
||||
private val _execApprovalsRefreshing = MutableStateFlow(false)
|
||||
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
|
||||
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
|
||||
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
|
||||
private val execApprovalsRefreshSeq = AtomicLong(0)
|
||||
private val execApprovalsStateLock = Any()
|
||||
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
|
||||
private val _channelsRefreshing = MutableStateFlow(false)
|
||||
@@ -448,6 +460,7 @@ class NodeRuntime(
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshExecApprovalsFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -477,6 +490,11 @@ class NodeRuntime(
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
)
|
||||
invalidateExecApprovalRefreshes()
|
||||
resolvedExecApprovalIds.clear()
|
||||
_execApprovals.value = emptyList()
|
||||
_execApprovalsRefreshing.value = false
|
||||
_execApprovalsErrorText.value = null
|
||||
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
|
||||
_dreamingSummary.value = GatewayDreamingSummary()
|
||||
_healthLogsSummary.value = GatewayHealthLogsSummary()
|
||||
@@ -632,7 +650,11 @@ class NodeRuntime(
|
||||
put("idempotencyKey", JsonPrimitive(idempotencyKey))
|
||||
}
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
val ack = parseChatSendAck(json, response)
|
||||
ack.copy(runId = ack.runId ?: idempotencyKey)
|
||||
},
|
||||
refreshAfterTerminalSuccess = {
|
||||
chat.refresh()
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Voice-tab replies should speak through the dedicated reply speaker.
|
||||
@@ -820,6 +842,24 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
val normalizedId = id.trim()
|
||||
val normalizedDecision = decision.trim()
|
||||
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
|
||||
scope.launch {
|
||||
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
scope.launch {
|
||||
refreshChannelsFromGateway()
|
||||
@@ -995,6 +1035,9 @@ class NodeRuntime(
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
} else {
|
||||
stopManualVoiceSession()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
|
||||
@@ -1824,11 +1867,47 @@ class NodeRuntime(
|
||||
if (event == "update.available") {
|
||||
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
|
||||
}
|
||||
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun handleExecApprovalGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
) {
|
||||
when (event) {
|
||||
"exec.approval.requested" -> {
|
||||
val approvalId = parseExecApprovalEventId(payloadJson)
|
||||
approvalId?.let(resolvedExecApprovalIds::remove)
|
||||
scope.launch {
|
||||
if (approvalId == null) {
|
||||
refreshExecApprovalsFromGateway()
|
||||
} else {
|
||||
refreshExecApprovalFromGateway(approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
"exec.approval.resolved" -> {
|
||||
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
|
||||
markExecApprovalResolved(approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseExecApprovalEventId(payloadJson: String?): String? =
|
||||
try {
|
||||
payloadJson
|
||||
?.let { json.parseToJsonElement(it).asObjectOrNull() }
|
||||
?.get("id")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
|
||||
return try {
|
||||
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
|
||||
@@ -1843,15 +1922,6 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChatSendRunId(response: String): String? {
|
||||
return try {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
|
||||
root["runId"].asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTalkSessionId(response: String): String {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull()
|
||||
val sessionId =
|
||||
@@ -2084,6 +2154,196 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshExecApprovalsFromGateway() {
|
||||
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
|
||||
_execApprovalsRefreshing.value = true
|
||||
_execApprovalsErrorText.value = null
|
||||
if (!operatorConnected) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovals.value = emptyList()
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res = operatorSession.request("exec.approval.list", "{}")
|
||||
val existing = _execApprovals.value.associateBy { it.id }
|
||||
val rows =
|
||||
parseGatewayExecApprovalListPayload(res, json)
|
||||
.filterNot { it.id in resolvedExecApprovalIds }
|
||||
.map { row ->
|
||||
val hydrated =
|
||||
try {
|
||||
fetchExecApprovalDetailFromGateway(
|
||||
id = row.id,
|
||||
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
|
||||
val current = existing[row.id]
|
||||
if (current == null) {
|
||||
hydrated
|
||||
} else {
|
||||
hydrated.copy(
|
||||
resolvingDecision = current.resolvingDecision,
|
||||
errorText = current.errorText ?: hydrated.errorText,
|
||||
)
|
||||
}
|
||||
}
|
||||
publishExecApprovalsIfCurrent(refreshGeneration, rows)
|
||||
} catch (_: Throwable) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovalsErrorText.value = "Could not load approvals."
|
||||
}
|
||||
} finally {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshExecApprovalFromGateway(id: String) {
|
||||
if (!operatorConnected) return
|
||||
if (id in resolvedExecApprovalIds) return
|
||||
try {
|
||||
val current = _execApprovals.value.firstOrNull { it.id == id }
|
||||
val row =
|
||||
fetchExecApprovalDetailFromGateway(
|
||||
id = id,
|
||||
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
|
||||
) ?: return
|
||||
if (id in resolvedExecApprovalIds) return
|
||||
invalidateExecApprovalRefreshes()
|
||||
upsertExecApproval(row)
|
||||
} catch (_: Throwable) {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchExecApprovalDetailFromGateway(
|
||||
id: String,
|
||||
createdAtMs: Long,
|
||||
): GatewayExecApprovalSummary? {
|
||||
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
|
||||
val res = operatorSession.request("exec.approval.get", params)
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
||||
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
|
||||
}
|
||||
|
||||
private suspend fun resolveExecApprovalOnGateway(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || id in resolvedExecApprovalIds) return
|
||||
val currentRows = _execApprovals.value
|
||||
if (currentRows.none { it.id == id }) return
|
||||
invalidateExecApprovalRefreshes()
|
||||
_execApprovals.value =
|
||||
currentRows.map { row ->
|
||||
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
|
||||
}
|
||||
}
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("decision", JsonPrimitive(decision))
|
||||
}.toString()
|
||||
operatorSession.request("exec.approval.resolve", params)
|
||||
markExecApprovalResolved(id)
|
||||
} catch (_: Throwable) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || id in resolvedExecApprovalIds) return
|
||||
_execApprovals.value =
|
||||
_execApprovals.value.map { row ->
|
||||
if (row.id == id) {
|
||||
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
|
||||
if (row.isExpiredExecApproval()) return
|
||||
val rows = _execApprovals.value
|
||||
val replaced = rows.any { it.id == row.id }
|
||||
val nextRows =
|
||||
(
|
||||
if (replaced) {
|
||||
rows.map { current ->
|
||||
if (current.id == row.id) {
|
||||
row.copy(
|
||||
resolvingDecision = current.resolvingDecision,
|
||||
errorText = current.errorText,
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows + row
|
||||
}
|
||||
).filterActiveExecApprovals()
|
||||
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
|
||||
_execApprovals.value = nextRows
|
||||
scheduleExecApprovalExpiryPrune(nextRows)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateExecApprovalRefreshes() {
|
||||
execApprovalsRefreshSeq.incrementAndGet()
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
|
||||
private fun markExecApprovalResolved(id: String) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
resolvedExecApprovalIds.add(id)
|
||||
invalidateExecApprovalRefreshes()
|
||||
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishExecApprovalsIfCurrent(
|
||||
refreshGeneration: Long,
|
||||
rows: List<GatewayExecApprovalSummary>,
|
||||
) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
|
||||
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
|
||||
_execApprovals.value = nextRows
|
||||
scheduleExecApprovalExpiryPrune(nextRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
|
||||
val now = System.currentTimeMillis()
|
||||
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
|
||||
scope.launch {
|
||||
delay((nextExpiry - now + 250).coerceAtLeast(0))
|
||||
pruneExpiredExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneExpiredExecApprovals() {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
|
||||
|
||||
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
|
||||
|
||||
private fun invalidateNodeCapabilityApprovalState() {
|
||||
val refreshGeneration = nodeApprovalRefreshGuard.begin()
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
@@ -2198,12 +2458,19 @@ class NodeRuntime(
|
||||
}.orEmpty()
|
||||
|
||||
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
|
||||
val sanitizedLine = sanitizeGatewayLogText(line)
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(line).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
|
||||
} ?: return GatewayLogEntry(
|
||||
time = null,
|
||||
level = null,
|
||||
subsystem = null,
|
||||
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
val meta = root["_meta"].asObjectOrNull()
|
||||
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
|
||||
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
|
||||
@@ -2221,7 +2488,7 @@ class NodeRuntime(
|
||||
?: root["message"].asStringOrNull()
|
||||
?: line
|
||||
val normalizedMessage =
|
||||
message
|
||||
sanitizeGatewayLogText(message)
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(240)
|
||||
@@ -2229,8 +2496,9 @@ class NodeRuntime(
|
||||
return GatewayLogEntry(
|
||||
time = time,
|
||||
level = level,
|
||||
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
|
||||
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
|
||||
message = normalizedMessage,
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2319,6 +2587,7 @@ class NodeRuntime(
|
||||
if (name.isEmpty()) return@mapNotNull null
|
||||
val missing = obj["missing"].asObjectOrNull()
|
||||
GatewaySkillSummary(
|
||||
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
|
||||
name = name,
|
||||
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
|
||||
@@ -2769,11 +3038,6 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
@@ -2846,6 +3110,7 @@ data class GatewaySkillsSummary(
|
||||
)
|
||||
|
||||
data class GatewaySkillSummary(
|
||||
val skillKey: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val source: String,
|
||||
@@ -3043,8 +3308,19 @@ data class GatewayLogEntry(
|
||||
val level: String?,
|
||||
val subsystem: String?,
|
||||
val message: String,
|
||||
val raw: String,
|
||||
)
|
||||
|
||||
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
|
||||
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
|
||||
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
|
||||
|
||||
internal fun sanitizeGatewayLogText(value: String): String =
|
||||
value
|
||||
.replace(gatewayAnsiControlPattern, "")
|
||||
.replace(gatewayEscapedAnsiControlPattern, "")
|
||||
.replace(gatewayVisibleSgrPattern, "")
|
||||
|
||||
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()
|
||||
|
||||
@@ -393,12 +393,6 @@ class SecurePrefs(
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Saves the paired gateway token under the current Android instance id. */
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
|
||||
fun loadGatewayBootstrapToken(): String? {
|
||||
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||
|
||||
@@ -6,14 +6,6 @@ internal fun normalizeMainKey(raw: String?): String {
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
/** Accepts only gateway session keys that can represent the main chat stream. */
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
/** Extracts the agent id from canonical agent-scoped main session keys. */
|
||||
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -19,11 +20,21 @@ import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class ChatController(
|
||||
class ChatController internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val session: GatewaySession,
|
||||
private val json: Json,
|
||||
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
|
||||
) {
|
||||
constructor(
|
||||
scope: CoroutineScope,
|
||||
session: GatewaySession,
|
||||
json: Json,
|
||||
) : this(
|
||||
scope = scope,
|
||||
json = json,
|
||||
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
|
||||
)
|
||||
|
||||
private var appliedMainSessionKey = "main"
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
@@ -267,8 +278,9 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
val res = requestGateway("chat.send", params.toString())
|
||||
val ack = parseChatSendAck(json, res)
|
||||
val actualRunId = ack.runId ?: runId
|
||||
if (actualRunId != runId) {
|
||||
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
@@ -279,7 +291,24 @@ class ChatController(
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
true
|
||||
if (ack.isTerminal) {
|
||||
clearPendingRun(actualRunId)
|
||||
removeOptimisticMessage(actualRunId)
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
if (ack.isTerminalSuccess) {
|
||||
refreshCurrentHistoryBestEffort()
|
||||
true
|
||||
} else {
|
||||
// Terminal timeout/error means the gateway did not accept a runnable turn.
|
||||
// Surface failed acceptance instead of letting a cleared composer look successful.
|
||||
_errorText.value = "Chat failed before the run started; try again."
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
@@ -303,7 +332,7 @@ class ChatController(
|
||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||
put("runId", JsonPrimitive(runId))
|
||||
}
|
||||
session.request("chat.abort", params.toString())
|
||||
requestGateway("chat.abort", params.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -356,7 +385,7 @@ class ChatController(
|
||||
) {
|
||||
try {
|
||||
val historyJson =
|
||||
session.request(
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
|
||||
)
|
||||
@@ -391,7 +420,7 @@ class ChatController(
|
||||
put("includeUnknown", JsonPrimitive(false))
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = session.request("sessions.list", params.toString())
|
||||
val res = requestGateway("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
@@ -408,7 +437,7 @@ class ChatController(
|
||||
if (!force && last != null && now - last < 10_000) return
|
||||
lastHealthPollAtMs = now
|
||||
try {
|
||||
session.request("health", null)
|
||||
requestGateway("health", null)
|
||||
_healthOk.value = true
|
||||
} catch (_: Throwable) {
|
||||
_healthOk.value = false
|
||||
@@ -451,7 +480,7 @@ class ChatController(
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
session.request(
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
@@ -509,8 +538,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
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
|
||||
@@ -632,6 +660,45 @@ class ChatController(
|
||||
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
|
||||
}
|
||||
|
||||
private fun refreshCurrentHistoryBestEffort() {
|
||||
scope.launch {
|
||||
try {
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
if (
|
||||
!isCurrentHistoryLoad(
|
||||
currentSessionKey,
|
||||
_sessionKey.value,
|
||||
currentGeneration,
|
||||
historyLoadGeneration.get(),
|
||||
)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
val history =
|
||||
parseHistory(
|
||||
historyJson,
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -679,9 +746,16 @@ class ChatController(
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
obj["key"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty {
|
||||
obj["sessionKey"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
@@ -728,17 +802,6 @@ class ChatController(
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
try {
|
||||
json
|
||||
.parseToJsonElement(resJson)
|
||||
.asObjectOrNull()
|
||||
?.get("runId")
|
||||
.asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun normalizeThinking(raw: String): String =
|
||||
when (raw.trim().lowercase()) {
|
||||
"low" -> "low"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal data class ChatSendAck(
|
||||
val runId: String?,
|
||||
val status: String?,
|
||||
) {
|
||||
val normalizedStatus: String
|
||||
get() = status?.trim()?.lowercase().orEmpty()
|
||||
|
||||
val isTerminalSuccess: Boolean
|
||||
get() = normalizedStatus == "ok"
|
||||
|
||||
val isTerminalFailure: Boolean
|
||||
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
|
||||
|
||||
val isTerminal: Boolean
|
||||
get() = isTerminalSuccess || isTerminalFailure
|
||||
}
|
||||
|
||||
internal fun chatSendAckHistorySinceSeconds(
|
||||
ack: ChatSendAck,
|
||||
startedAtSeconds: Double,
|
||||
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
|
||||
|
||||
internal fun parseChatSendAck(
|
||||
json: Json,
|
||||
responseJson: String,
|
||||
): ChatSendAck =
|
||||
try {
|
||||
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
|
||||
ChatSendAck(
|
||||
runId = obj?.get("runId").asStringOrNull(),
|
||||
status = obj?.get("status").asStringOrNull(),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
ChatSendAck(runId = null, status = null)
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
@@ -12,6 +11,7 @@ import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -56,7 +56,7 @@ private fun createDnsResolver(context: Context): DnsResolver =
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -166,14 +166,6 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -197,7 +189,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
|
||||
@@ -260,24 +260,6 @@ class GatewaySession(
|
||||
currentConnection?.closeQuietly()
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
|
||||
|
||||
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
|
||||
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
|
||||
val refreshed =
|
||||
refreshPluginSurfaceUrl(
|
||||
method = "node.pluginSurface.refresh",
|
||||
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
if (!refreshed.isNullOrBlank()) {
|
||||
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
|
||||
}
|
||||
return refreshed
|
||||
}
|
||||
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
|
||||
suspend fun sendNodeEvent(
|
||||
event: String,
|
||||
@@ -297,28 +279,6 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshPluginSurfaceUrl(
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val conn = currentConnection ?: return null
|
||||
return try {
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
if (!res.ok) return null
|
||||
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
|
||||
val raw =
|
||||
obj["pluginSurfaceUrls"]
|
||||
.asObjectOrNull()
|
||||
?.get("canvas")
|
||||
.asStringOrNull()
|
||||
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
|
||||
suspend fun sendNodeEventDetailed(
|
||||
event: String,
|
||||
|
||||
@@ -97,8 +97,6 @@ class CanvasController {
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
@@ -205,24 +203,6 @@ class CanvasController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
}
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
|
||||
suspend fun snapshotBase64(
|
||||
format: SnapshotFormat,
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -63,7 +64,7 @@ private class AndroidDeviceAppSource(
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
visibleInstalledApplications(packageManager)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
@@ -90,6 +91,13 @@ private class AndroidDeviceAppSource(
|
||||
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
|
||||
// Android package visibility intentionally bounds this result to packages the app can see.
|
||||
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
|
||||
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceAppsRequest(
|
||||
|
||||
@@ -109,6 +109,3 @@ fun normalizeMainKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
return if (trimmed.isEmpty()) null else trimmed
|
||||
}
|
||||
|
||||
/** Returns true only for the canonical main-session key understood by gateway UI. */
|
||||
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSeparatedColumn
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
@@ -94,7 +95,11 @@ internal fun CommandPalette(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close search",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
CommandAvatar(text = "OC")
|
||||
}
|
||||
@@ -262,19 +267,6 @@ private fun CommandSessionListRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandAvatar(text: String) {
|
||||
Surface(
|
||||
|
||||
@@ -5,8 +5,7 @@ import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -92,19 +91,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
@@ -115,23 +114,6 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingHealthRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(7.dp))
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
|
||||
@@ -206,9 +206,6 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
|
||||
|
||||
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
|
||||
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
|
||||
val setupCode =
|
||||
|
||||
@@ -7,7 +7,10 @@ import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -15,13 +18,18 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -43,6 +51,7 @@ internal fun HealthLogsSettingsScreen(
|
||||
val logsSummary by viewModel.healthLogsSummary.collectAsState()
|
||||
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
|
||||
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
|
||||
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -52,6 +61,11 @@ internal fun HealthLogsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
selectedLogEntry?.let { entry ->
|
||||
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
|
||||
return
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Health",
|
||||
subtitle = "Gateway status, phone node readiness, and recent log stream.",
|
||||
@@ -93,7 +107,46 @@ internal fun HealthLogsSettingsScreen(
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogDetailSettingsScreen(
|
||||
entry: GatewayLogEntry,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
SettingsDetailFrame(
|
||||
title = "Log Entry",
|
||||
subtitle = "Readable gateway log detail.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Time", compactLogTime(entry.time)),
|
||||
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
|
||||
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
|
||||
),
|
||||
)
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = entry.raw.take(4_000),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,41 +166,26 @@ private fun HealthStatusPanel(
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
ClawStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogsPanel(
|
||||
isConnected: Boolean,
|
||||
summary: GatewayHealthLogsSummary,
|
||||
onLogClick: (GatewayLogEntry) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -170,7 +208,7 @@ private fun GatewayLogsPanel(
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry)
|
||||
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
@@ -185,9 +223,16 @@ private fun GatewayLogsPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
private fun GatewayLogRow(
|
||||
entry: GatewayLogEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClickLabel = "Open log entry", onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
@@ -199,6 +244,11 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1378,7 +1378,12 @@ private fun rememberPermissionState(
|
||||
photosGranted = permissions[photosPermission] ?: photosGranted
|
||||
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
|
||||
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
|
||||
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
} else {
|
||||
true
|
||||
}
|
||||
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
|
||||
smsGranted =
|
||||
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&
|
||||
|
||||
@@ -9,14 +9,10 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
|
||||
*/
|
||||
@@ -34,7 +30,6 @@ fun OpenClawTheme(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -55,21 +50,3 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay background token tuned for panels floating over the mobile canvas.
|
||||
*/
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = LocalOpenClawDarkTheme.current
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode keeps overlays away from pure-white glare on the app canvas.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
|
||||
*/
|
||||
@Composable
|
||||
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -2,6 +2,7 @@ package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
@@ -55,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** Session browser for recent and currently-live chat sessions. */
|
||||
/** Session browser for recent and current chat sessions. */
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -73,7 +74,7 @@ internal fun SessionsScreen(
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
@@ -92,12 +93,12 @@ internal fun SessionsScreen(
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
@@ -106,16 +107,16 @@ internal fun SessionsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@ private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
live: Boolean = false,
|
||||
showDot: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -198,7 +199,7 @@ private fun FilterPill(
|
||||
) {
|
||||
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
if (live) {
|
||||
if (showDot) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
@@ -258,7 +259,7 @@ private fun SessionRow(
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SessionMiniTag(text = "Workspace")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,19 +274,6 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionOutlineIconButton(
|
||||
icon: ImageVector,
|
||||
@@ -320,21 +308,21 @@ private fun SessionMiniTag(text: String) {
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Live,
|
||||
Current,
|
||||
}
|
||||
|
||||
/** Empty-state title selected by the active session browser filter. */
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Live -> "No live session"
|
||||
SessionFilter.Current -> "No current session"
|
||||
}
|
||||
|
||||
/** Empty-state body selected by the active session browser filter. */
|
||||
private fun emptySessionBody(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "Start a new conversation and it will show up here."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
SessionFilter.Current -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
/** Formats session timestamps for compact mobile metadata. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayCronJobSummary
|
||||
import ai.openclaw.app.GatewayExecApprovalSummary
|
||||
import ai.openclaw.app.GatewayUsageProviderSummary
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
@@ -14,6 +15,7 @@ import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
@@ -90,7 +92,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -106,6 +107,7 @@ internal enum class SettingsRoute {
|
||||
Profile,
|
||||
Voice,
|
||||
Agents,
|
||||
ProvidersModels,
|
||||
Approvals,
|
||||
CronJobs,
|
||||
Usage,
|
||||
@@ -136,6 +138,7 @@ internal fun SettingsDetailScreen(
|
||||
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
@@ -299,29 +302,62 @@ private fun ApprovalsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val execApprovals by viewModel.execApprovals.collectAsState()
|
||||
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
|
||||
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val waitingCount = pendingToolCalls.count { it.isError != true }
|
||||
val issueCount = pendingToolCalls.count { it.isError == true }
|
||||
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Pending", waitingCount.toString()),
|
||||
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
|
||||
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
|
||||
SettingsMetric("Issues", issueCount.toString()),
|
||||
SettingsMetric("Active Runs", pendingRunCount.toString()),
|
||||
),
|
||||
)
|
||||
if (pendingToolCalls.isEmpty()) {
|
||||
ClawSecondaryButton(
|
||||
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshExecApprovals,
|
||||
enabled = isConnected && !execApprovalsRefreshing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (execApprovalsErrorText != null) {
|
||||
ClawPanel {
|
||||
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
if (!isConnected) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
} else if (execApprovals.isEmpty()) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ApprovalsPanel(toolCalls = pendingToolCalls)
|
||||
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
|
||||
}
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
SessionToolCallsPanel(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,6 +856,7 @@ private fun GatewaySettingsScreen(
|
||||
var bootstrapTokenInput by remember { mutableStateOf("") }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var validationText by remember { mutableStateOf<String?>(null) }
|
||||
var showSetupCodeHelp by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
@@ -840,7 +877,17 @@ private fun GatewaySettingsScreen(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
|
||||
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
|
||||
}
|
||||
if (showSetupCodeHelp) {
|
||||
Text(
|
||||
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
@@ -1061,7 +1108,11 @@ internal fun SettingsDetailFrame(
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
SettingsBackButton(onClick = onBack)
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
)
|
||||
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
SettingsIconMark(icon = icon)
|
||||
}
|
||||
@@ -1098,7 +1149,70 @@ internal data class SettingsMetric(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
private fun ExecApprovalsPanel(
|
||||
approvals: List<GatewayExecApprovalSummary>,
|
||||
onResolve: (String, String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
approvals.forEach { approval ->
|
||||
ExecApprovalCard(approval = approval, onResolve = onResolve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExecApprovalCard(
|
||||
approval: GatewayExecApprovalSummary,
|
||||
onResolve: (String, String) -> Unit,
|
||||
) {
|
||||
val resolving = approval.resolvingDecision != null
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
approval.commandPreview?.let { preview ->
|
||||
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
|
||||
}
|
||||
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
approval.errorText?.let { errorText ->
|
||||
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if ("allow-once" in approval.allowedDecisions) {
|
||||
ClawPrimaryButton(
|
||||
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
|
||||
onClick = { onResolve(approval.id, "allow-once") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if ("allow-always" in approval.allowedDecisions) {
|
||||
ClawSecondaryButton(
|
||||
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
|
||||
onClick = { onResolve(approval.id, "allow-always") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if ("deny" in approval.allowedDecisions) {
|
||||
ClawSecondaryButton(
|
||||
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
|
||||
onClick = { onResolve(approval.id, "deny") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawListPanel(items = toolCalls) { toolCall ->
|
||||
ApprovalListRow(toolCall = toolCall)
|
||||
}
|
||||
@@ -1231,6 +1345,30 @@ private fun approvalSubtitle(
|
||||
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
|
||||
}
|
||||
|
||||
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
|
||||
val target =
|
||||
when {
|
||||
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
|
||||
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
|
||||
else -> "Gateway"
|
||||
}
|
||||
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
|
||||
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
|
||||
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
|
||||
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun formatApprovalDuration(deltaMs: Long): String {
|
||||
val safeDelta = deltaMs.coerceAtLeast(0L)
|
||||
val minutes = safeDelta / 60_000L
|
||||
val hours = minutes / 60L
|
||||
return when {
|
||||
minutes < 1 -> "soon"
|
||||
hours < 1 -> "${minutes}m"
|
||||
else -> "${hours}h"
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
|
||||
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
|
||||
|
||||
@@ -1394,15 +1532,6 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsBackButton(onClick: () -> Unit) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsIconMark(icon: ImageVector) {
|
||||
Surface(
|
||||
|
||||
@@ -1253,16 +1253,6 @@ private fun settingsPrimaryButtonColors() =
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
/** Destructive button colors for permission and capability settings actions. */
|
||||
@Composable
|
||||
private fun settingsDangerButtonColors() =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileDanger,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
/** Opens this app's Android settings page for permissions that require system UI. */
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,24 @@ import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -37,6 +44,7 @@ internal fun SkillsSettingsScreen(
|
||||
val skills = skillsSummary.skills
|
||||
val readyCount = skills.count { skillReady(it) }
|
||||
val needsSetupCount = skills.count { skillNeedsSetup(it) }
|
||||
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -44,6 +52,17 @@ internal fun SkillsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
selectedSkillKey?.let { skillKey ->
|
||||
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
|
||||
SkillDetailSettingsScreen(
|
||||
skill = selectedSkill,
|
||||
skillKey = skillKey,
|
||||
isConnected = isConnected,
|
||||
onBack = { selectedSkillKey = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skills",
|
||||
subtitle = "Installed capabilities available to OpenClaw.",
|
||||
@@ -83,25 +102,117 @@ internal fun SkillsSettingsScreen(
|
||||
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> SkillsPanel(skills = skills)
|
||||
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill)
|
||||
private fun SkillDetailSettingsScreen(
|
||||
skill: GatewaySkillSummary?,
|
||||
skillKey: String,
|
||||
isConnected: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = skill?.name ?: skillKey,
|
||||
subtitle = "Inspect installed skill capability and setup state.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
skill?.let { summary ->
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Status", skillStatusText(summary)),
|
||||
SettingsMetric("Source", skillSourceLabel(summary)),
|
||||
SettingsMetric("Missing", summary.missingCount.toString()),
|
||||
),
|
||||
)
|
||||
SkillSetupPanel(summary)
|
||||
}
|
||||
SkillDetailPanel(skill = skill, isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillDetailPanel(
|
||||
skill: GatewaySkillSummary?,
|
||||
isConnected: Boolean,
|
||||
) {
|
||||
if (!isConnected) {
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (skill == null) {
|
||||
ClawPanel {
|
||||
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Skill Key", skill.skillKey),
|
||||
SettingsMetric("Display", skill.name),
|
||||
SettingsMetric("Source", skillSourceLabel(skill)),
|
||||
SettingsMetric("Install Options", skill.installCount.toString()),
|
||||
),
|
||||
)
|
||||
skill.description?.let { description ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(
|
||||
skills: List<GatewaySkillSummary>,
|
||||
onSkillClick: (GatewaySkillSummary) -> Unit,
|
||||
) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(
|
||||
skill: GatewaySkillSummary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
|
||||
leading = { ClawTextBadge(text = skillBadge(skill)) },
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +246,15 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
|
||||
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
|
||||
when {
|
||||
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
|
||||
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
|
||||
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
|
||||
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
|
||||
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
|
||||
}
|
||||
|
||||
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
|
||||
when (skill.source) {
|
||||
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
@@ -68,6 +70,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -177,8 +180,8 @@ fun VoiceScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
@@ -267,12 +270,12 @@ private fun DictationScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -404,7 +407,7 @@ private fun TalkSessionScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
@@ -423,7 +426,7 @@ private fun TalkSessionScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -547,14 +550,19 @@ private fun VoiceHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -562,7 +570,7 @@ private fun VoiceHeader(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
@@ -571,7 +579,7 @@ private fun VoiceHeader(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
VoicePlainIconButton(
|
||||
ClawPlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
@@ -580,34 +588,6 @@ private fun VoiceHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlainIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHero(
|
||||
gatewayStatus: String,
|
||||
@@ -861,8 +841,10 @@ private fun VoiceOrb(
|
||||
Surface(
|
||||
modifier = Modifier.size(112.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 7.dp,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -875,7 +857,7 @@ private fun VoiceOrb(
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
tint = Color.White,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
@@ -892,7 +874,7 @@ private fun Waveform(active: Boolean) {
|
||||
Modifier
|
||||
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
@@ -39,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
@@ -63,6 +65,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -153,12 +156,11 @@ fun ChatScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onMore = {
|
||||
@@ -261,11 +263,11 @@ private fun ChatSessionSwitcher(
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
@@ -288,11 +290,11 @@ private fun ChatSessionChip(
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -307,48 +309,56 @@ private fun ChatSessionChip(
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
healthOk -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Neutral
|
||||
healthOk -> ClawStatus.Success
|
||||
else -> ClawStatus.Danger
|
||||
},
|
||||
)
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +375,13 @@ private fun ModelPill(
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color =
|
||||
when (status) {
|
||||
ClawStatus.Success -> ClawTheme.colors.successSoft
|
||||
ClawStatus.Warning -> ClawTheme.colors.warningSoft
|
||||
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
|
||||
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
|
||||
},
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
@@ -577,13 +593,15 @@ private fun ChatBubble(
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
@@ -764,7 +782,7 @@ private fun ChatContextMeter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
@@ -936,7 +954,7 @@ internal fun resolveChatContextUsage(
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
@@ -973,24 +991,6 @@ private fun userFacingChatError(error: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes persisted thinking values into compact UI labels. */
|
||||
private fun thinkingDisplay(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
/** Converts displayed thinking labels back to gateway request values. */
|
||||
private fun thinkingValue(display: String): String =
|
||||
when (display.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Cycles through context budget presets from the compact composer control. */
|
||||
private fun nextThinkingValue(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
|
||||
@@ -185,6 +185,53 @@ internal fun ClawIconButton(
|
||||
}
|
||||
}
|
||||
|
||||
/** Transparent circular icon button for low-emphasis toolbar actions. */
|
||||
@Composable
|
||||
internal fun ClawPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact label/value row for health and readiness summaries. */
|
||||
@Composable
|
||||
internal fun ClawStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
ClawStatusPill(
|
||||
text = value,
|
||||
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact status chip with a semantic color dot. */
|
||||
@Composable
|
||||
internal fun ClawStatusPill(
|
||||
|
||||
@@ -95,15 +95,17 @@ internal fun ClawBottomNav(
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -131,13 +133,13 @@ private fun ClawBottomNavItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
|
||||
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -27,31 +27,11 @@ internal fun ClawPanel(
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-sheet container with the app surface treatment and top-only rounding.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClawSheetSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(18.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
border = null,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
|
||||
@@ -4,7 +4,6 @@ import ai.openclaw.app.ui.LocalMobileColors
|
||||
import ai.openclaw.app.ui.darkMobileColors
|
||||
import ai.openclaw.app.ui.lightMobileColors
|
||||
import ai.openclaw.app.ui.mobileFontFamily
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
@@ -190,12 +189,6 @@ internal fun ClawDesignTheme(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the system dark-mode preference for callers that expose theme selection.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
|
||||
|
||||
private fun clawTypography(fontFamily: FontFamily) =
|
||||
ClawTypography(
|
||||
display =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -43,7 +44,7 @@ data class VoiceConversationEntry(
|
||||
)
|
||||
|
||||
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
|
||||
class MicCaptureManager(
|
||||
internal class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val createTranscriptionSession: suspend () -> String,
|
||||
@@ -54,11 +55,12 @@ class MicCaptureManager(
|
||||
) -> Unit,
|
||||
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
|
||||
/**
|
||||
* Send [message] to the gateway and return the run ID.
|
||||
* Send [message] to the gateway and return the full chat.send ACK.
|
||||
* [onRunIdKnown] is called with the idempotency key *before* the network
|
||||
* round-trip so [pendingRunId] is set before any chat events can arrive.
|
||||
*/
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
|
||||
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
private val speakAssistantReply: suspend (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
@@ -483,24 +485,30 @@ class MicCaptureManager(
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val runId =
|
||||
val ack =
|
||||
sendToGateway(next) { earlyRunId ->
|
||||
// Called with the idempotency key before chat.send fires so that
|
||||
// pendingRunId is populated before any chat events can arrive.
|
||||
pendingRunId = earlyRunId
|
||||
}
|
||||
val runId = ack.runId
|
||||
// Update to the real runId if the gateway returned a different one.
|
||||
if (runId != null && runId != pendingRunId) pendingRunId = runId
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
removeFirstQueuedMessage()
|
||||
publishQueue()
|
||||
_isSending.value = false
|
||||
pendingAssistantEntryId = null
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
armPendingRunTimeout(runId)
|
||||
when {
|
||||
ack.isTerminalSuccess -> {
|
||||
completePendingTurn()
|
||||
refreshAfterTerminalSuccess()
|
||||
}
|
||||
ack.isTerminalFailure -> {
|
||||
completePendingTurn()
|
||||
_statusText.value = "Send failed: Chat failed before the run started; try again."
|
||||
}
|
||||
runId == null -> {
|
||||
completePendingTurn()
|
||||
}
|
||||
else -> {
|
||||
armPendingRunTimeout(runId)
|
||||
}
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -108,7 +111,6 @@ class TalkModeManager internal constructor(
|
||||
private const val tag = "TalkMode"
|
||||
private const val realtimeSampleRateHz = 24_000
|
||||
private const val realtimeAudioFrameMs = 100
|
||||
private const val listenWatchdogMs = 12_000L
|
||||
private const val chatFinalWaitMs = 45_000L
|
||||
private const val maxCachedRunCompletions = 128
|
||||
private const val maxConversationEntries = 40
|
||||
@@ -381,11 +383,20 @@ class TalkModeManager internal constructor(
|
||||
reloadConfig()
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
val prompt = buildPrompt(command)
|
||||
val runId = sendChat(prompt, session)
|
||||
val ok = waitForChatFinal(runId)
|
||||
val ack = sendChat(prompt, session)
|
||||
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
|
||||
if (ack.isTerminalFailure) {
|
||||
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
|
||||
return@launch
|
||||
}
|
||||
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
|
||||
val assistant =
|
||||
consumeRunText(runId)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
?: waitForAssistantText(
|
||||
session,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
if (!assistant.isNullOrBlank()) {
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
@@ -398,8 +409,9 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "speakWakeCommand failed: ${err.message}")
|
||||
} finally {
|
||||
onComplete()
|
||||
}
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,16 +1616,26 @@ class TalkModeManager internal constructor(
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
||||
val runId = sendChat(prompt, session)
|
||||
Log.d(tag, "chat.send ok runId=$runId")
|
||||
val ok = waitForChatFinal(runId)
|
||||
val ack = sendChat(prompt, session)
|
||||
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
|
||||
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
|
||||
if (ack.isTerminalFailure) {
|
||||
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
|
||||
start()
|
||||
return
|
||||
}
|
||||
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
|
||||
if (!ok) {
|
||||
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
||||
}
|
||||
// Use text cached from the final event first — avoids chat.history polling
|
||||
val assistant =
|
||||
consumeRunText(runId)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
?: waitForAssistantText(
|
||||
session,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
if (assistant.isNullOrBlank()) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "assistant text timeout runId=$runId")
|
||||
@@ -1679,7 +1701,7 @@ class TalkModeManager internal constructor(
|
||||
private suspend fun sendChat(
|
||||
message: String,
|
||||
session: GatewaySession,
|
||||
): String {
|
||||
): ChatSendAck {
|
||||
val runId = UUID.randomUUID().toString()
|
||||
armPendingRun(runId)
|
||||
val params =
|
||||
@@ -1692,11 +1714,15 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
try {
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
val parsed = parseChatSendAck(json, res)
|
||||
val actualRunId = parsed.runId ?: runId
|
||||
if (actualRunId != runId) {
|
||||
pendingRunId = actualRunId
|
||||
}
|
||||
return parsed
|
||||
if (parsed.isTerminal) {
|
||||
clearPendingRun(actualRunId)
|
||||
}
|
||||
return parsed.copy(runId = actualRunId)
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
throw err
|
||||
@@ -1777,7 +1803,7 @@ class TalkModeManager internal constructor(
|
||||
|
||||
private suspend fun waitForAssistantText(
|
||||
session: GatewaySession,
|
||||
sinceSeconds: Double,
|
||||
sinceSeconds: Double?,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_IMAGES"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VIDEO"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
|
||||
@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
|
||||
@Test
|
||||
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayExecApprovalParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parsesGatewayExecApprovalListPayload() {
|
||||
val rows =
|
||||
parseGatewayExecApprovalListPayload(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "approval-2",
|
||||
"createdAtMs": 20,
|
||||
"expiresAtMs": 120,
|
||||
"request": {
|
||||
"host": "node",
|
||||
"nodeId": "node-1",
|
||||
"agentId": "agent-1",
|
||||
"command": "Sanitized command",
|
||||
"commandPreview": "Sanitized preview",
|
||||
"systemRunPlan": {
|
||||
"commandText": "/bin/sh -lc 'echo secret'",
|
||||
"commandPreview": "echo secret"
|
||||
},
|
||||
"allowedDecisions": ["allow-once", "deny"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "approval-1",
|
||||
"createdAtMs": 10,
|
||||
"expiresAtMs": 110,
|
||||
"request": {
|
||||
"host": "gateway",
|
||||
"command": "pnpm test --token secret",
|
||||
"commandPreview": "pnpm test",
|
||||
"unavailableDecisions": ["allow-always"]
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent(),
|
||||
json,
|
||||
)
|
||||
|
||||
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
|
||||
assertEquals("pnpm test --token secret", rows[0].commandText)
|
||||
assertEquals("pnpm test", rows[0].commandPreview)
|
||||
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
|
||||
assertEquals("Sanitized command", rows[1].commandText)
|
||||
assertEquals("Sanitized preview", rows[1].commandPreview)
|
||||
assertEquals("node-1", rows[1].nodeId)
|
||||
assertEquals("agent-1", rows[1].agentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGatewayExecApprovalGetPayload() {
|
||||
val root =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"id": "approval-1",
|
||||
"commandText": "rm -rf build",
|
||||
"commandPreview": "rm build",
|
||||
"allowedDecisions": ["allow-once", "allow-always", "deny"],
|
||||
"host": "gateway",
|
||||
"nodeId": null,
|
||||
"agentId": "agent-main",
|
||||
"expiresAtMs": 200
|
||||
}
|
||||
""".trimIndent(),
|
||||
).asObjectOrNull()
|
||||
|
||||
requireNotNull(root)
|
||||
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
|
||||
|
||||
requireNotNull(row)
|
||||
assertEquals("approval-1", row.id)
|
||||
assertEquals("rm -rf build", row.commandText)
|
||||
assertEquals("rm build", row.commandPreview)
|
||||
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
|
||||
assertEquals("gateway", row.host)
|
||||
assertNull(row.nodeId)
|
||||
assertEquals("agent-main", row.agentId)
|
||||
assertEquals(100L, row.createdAtMs)
|
||||
assertEquals(200L, row.expiresAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresMalformedGatewayExecApprovalListPayload() {
|
||||
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
|
||||
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayLogTextTest {
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
|
||||
assertEquals(
|
||||
"hindsight: Skipping retain",
|
||||
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
|
||||
assertEquals(
|
||||
"hindsight: Skipping retain",
|
||||
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
|
||||
assertEquals(
|
||||
"error and bold",
|
||||
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
|
||||
assertEquals(
|
||||
"""{"1":"hindsight: Skipping retain"}""",
|
||||
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
|
||||
assertEquals(
|
||||
"cache ttl [5m] expired",
|
||||
sanitizeGatewayLogText("cache ttl [5m] expired"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerTerminalAckTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
|
||||
runTest {
|
||||
var requestedMethod: String? = null
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
requestedMethod = method
|
||||
"""{"runId":"run-timeout","status":"timeout"}"""
|
||||
},
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that times out before start",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertFalse(accepted)
|
||||
assertEquals("chat.send", requestedMethod)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
|
||||
runTest {
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that started",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertTrue(accepted)
|
||||
assertEquals(1, controller.pendingRunCount.value)
|
||||
assertNull(controller.errorText.value)
|
||||
assertTrue(controller.messages.value.hasUserText("message that started"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
|
||||
runTest {
|
||||
val requestedMethods = mutableListOf<String>()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
requestedMethods += method
|
||||
when (method) {
|
||||
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
|
||||
"chat.history" ->
|
||||
"""
|
||||
{
|
||||
"sessionId": "session-1",
|
||||
"messages": [
|
||||
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
else -> "{}"
|
||||
}
|
||||
},
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that already completed",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(accepted)
|
||||
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertNull(controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that already completed"))
|
||||
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
|
||||
runTest {
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that errors before start",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertFalse(accepted)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
|
||||
}
|
||||
|
||||
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
|
||||
any { message ->
|
||||
message.role == "user" && message.content.any { part -> part.text == text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatSendAckTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
|
||||
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
|
||||
|
||||
assertEquals("run-1", ack.runId)
|
||||
assertEquals("started", ack.normalizedStatus)
|
||||
assertFalse(ack.isTerminal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckMarksOkAsTerminalSuccess() {
|
||||
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
|
||||
|
||||
assertEquals("run-ok", ack.runId)
|
||||
assertEquals("ok", ack.normalizedStatus)
|
||||
assertTrue(ack.isTerminal)
|
||||
assertTrue(ack.isTerminalSuccess)
|
||||
assertFalse(ack.isTerminalFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
|
||||
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
|
||||
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
|
||||
|
||||
assertEquals("run-timeout", timeout.runId)
|
||||
assertTrue(timeout.isTerminal)
|
||||
assertFalse(timeout.isTerminalSuccess)
|
||||
assertTrue(timeout.isTerminalFailure)
|
||||
assertEquals("run-error", error.runId)
|
||||
assertTrue(error.isTerminal)
|
||||
assertFalse(error.isTerminalSuccess)
|
||||
assertTrue(error.isTerminalFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedOkAckUsesUnfilteredHistoryFallback() {
|
||||
val startedAt = 123.0
|
||||
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
|
||||
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
|
||||
|
||||
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
|
||||
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckToleratesMalformedPayloads() {
|
||||
val ack = parseChatSendAck(json, "not-json")
|
||||
|
||||
assertNull(ack.runId)
|
||||
assertEquals("", ack.normalizedStatus)
|
||||
assertFalse(ack.isTerminal)
|
||||
assertFalse(ack.isTerminalSuccess)
|
||||
assertFalse(ack.isTerminalFailure)
|
||||
}
|
||||
}
|
||||
@@ -204,17 +204,18 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
val qrJson =
|
||||
@@ -227,49 +228,55 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
|
||||
assertNull(resolved)
|
||||
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
|
||||
val qrJson = """{"setupCode":"invalid"}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
|
||||
val qrJson = """{"setupCode":{"nested":"value"}}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
|
||||
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertNull(resolved)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
|
||||
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -105,7 +107,7 @@ class ShellScreenLogicTest {
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
val providersRow = rows.single { it.title == "Providers" }
|
||||
assertEquals(Tab.Settings, providersRow.tab)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,10 +159,206 @@ class ShellScreenLogicTest {
|
||||
assertEquals("Node approval pending", rows.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
|
||||
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
|
||||
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
|
||||
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewHeaderRouteUsesFirstAttentionDestination() {
|
||||
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
|
||||
assertEquals(
|
||||
SettingsRoute.Approvals,
|
||||
overviewHeaderRoute(
|
||||
listOf(
|
||||
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
|
||||
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = true,
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes =
|
||||
listOf(
|
||||
GatewayNodeSummary(
|
||||
id = "android-node",
|
||||
displayName = "Android",
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = "Android",
|
||||
paired = true,
|
||||
connected = true,
|
||||
approvalState = GatewayNodeApprovalState.PendingReapproval,
|
||||
pendingRequestId = "node-request",
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
),
|
||||
),
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
pendingApprovals = 2,
|
||||
sessionCount = 4,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
|
||||
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
|
||||
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
|
||||
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
|
||||
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
|
||||
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
|
||||
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
|
||||
assertEquals("2", cards.single { it.title == "Approvals" }.value)
|
||||
assertEquals("4", cards.single { it.title == "Sessions" }.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = false,
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes =
|
||||
(1..3).map { index ->
|
||||
GatewayNodeSummary(
|
||||
id = "node-$index",
|
||||
displayName = "Node $index",
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = null,
|
||||
paired = true,
|
||||
connected = index <= 2,
|
||||
approvalState = GatewayNodeApprovalState.Approved,
|
||||
pendingRequestId = null,
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
)
|
||||
},
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
pendingApprovals = 0,
|
||||
sessionCount = 0,
|
||||
)
|
||||
|
||||
val nodes = cards.single { it.title == "Nodes" }
|
||||
assertEquals("2/3", nodes.value)
|
||||
assertEquals("67% online", nodes.subtitle)
|
||||
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = false,
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
pendingApprovals = 0,
|
||||
sessionCount = 0,
|
||||
)
|
||||
|
||||
val gateway = cards.single { it.title == "Gateway" }
|
||||
assertEquals("Healthy", gateway.value)
|
||||
assertEquals("All systems nominal", gateway.subtitle)
|
||||
assertEquals(ClawStatus.Success, gateway.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
|
||||
val agents =
|
||||
listOf(
|
||||
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
|
||||
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
|
||||
)
|
||||
|
||||
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
|
||||
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
|
||||
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
|
||||
val agents =
|
||||
listOf(
|
||||
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
|
||||
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
|
||||
)
|
||||
|
||||
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
|
||||
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
|
||||
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
|
||||
assertEquals(
|
||||
"Working · 2 active runs",
|
||||
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Monitoring · 50 sessions",
|
||||
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Gateway offline",
|
||||
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
|
||||
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
|
||||
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
|
||||
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
|
||||
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
|
||||
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
|
||||
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
|
||||
val channels =
|
||||
GatewayChannelsSummary(
|
||||
channels =
|
||||
listOf(
|
||||
GatewayChannelSummary(
|
||||
id = "matrix",
|
||||
label = "Matrix",
|
||||
accountCount = 1,
|
||||
enabled = true,
|
||||
configured = true,
|
||||
linked = true,
|
||||
running = true,
|
||||
connected = true,
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
|
||||
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -34,7 +35,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
null
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
},
|
||||
)
|
||||
|
||||
@@ -84,7 +85,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
"run-1"
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
},
|
||||
)
|
||||
|
||||
@@ -111,7 +112,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-voice-e2e")
|
||||
"run-voice-e2e"
|
||||
ChatSendAck(runId = "run-voice-e2e", status = "started")
|
||||
},
|
||||
)
|
||||
|
||||
@@ -134,6 +135,88 @@ class MicCaptureManagerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
|
||||
runTest {
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-terminal")
|
||||
ChatSendAck(runId = "run-terminal", status = "timeout")
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal ack message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf(VoiceConversationRole.User),
|
||||
manager.conversation.value.map { it.role },
|
||||
)
|
||||
assertEquals(
|
||||
"terminal ack message",
|
||||
manager.conversation.value
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
|
||||
runTest {
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-error")
|
||||
ChatSendAck(runId = "run-error", status = "error")
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal error message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
|
||||
runTest {
|
||||
var refreshCalls = 0
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-ok")
|
||||
ChatSendAck(runId = "run-ok", status = "ok")
|
||||
},
|
||||
refreshAfterTerminalSuccess = { refreshCalls += 1 },
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal ok message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals(1, refreshCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pcm16FramesAreEncodedAsPcmuFrames() {
|
||||
val manager = createManager()
|
||||
@@ -230,10 +313,11 @@ class MicCaptureManagerTest {
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
|
||||
createTranscriptionSession: suspend () -> String = { "transcription-1" },
|
||||
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-1")
|
||||
"run-1"
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
},
|
||||
refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
): MicCaptureManager =
|
||||
MicCaptureManager(
|
||||
context =
|
||||
@@ -245,6 +329,7 @@ class MicCaptureManagerTest {
|
||||
appendTranscriptionAudio = { _, _, _ -> },
|
||||
closeTranscriptionSession = closeTranscriptionSession,
|
||||
sendToGateway = sendToGateway,
|
||||
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
|
||||
)
|
||||
|
||||
private fun setPrivateField(
|
||||
|
||||
@@ -164,7 +164,7 @@ run_mode() {
|
||||
no_connect_flag=false
|
||||
fi
|
||||
|
||||
adb shell am broadcast \
|
||||
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
|
||||
-a "$RUN_ACTION" \
|
||||
-n "$RECEIVER" \
|
||||
--es mode "$test_mode" \
|
||||
@@ -224,7 +224,7 @@ adb logcat -d -v time |
|
||||
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
|
||||
|
||||
if [[ "$CLEANUP" -eq 1 ]]; then
|
||||
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
fi
|
||||
|
||||
echo "$ARTIFACT_DIR"
|
||||
|
||||
@@ -2,8 +2,24 @@ parent_config: ../../config/swiftlint.yml
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
- ShareExtension
|
||||
- ActivityWidget
|
||||
- WatchApp
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI
|
||||
|
||||
excluded:
|
||||
- ../macos
|
||||
|
||||
type_body_length:
|
||||
warning: 900
|
||||
error: 1300
|
||||
|
||||
custom_rules:
|
||||
openclaw_design_colors:
|
||||
name: "OpenClaw design colors"
|
||||
excluded:
|
||||
- Sources/Design/OpenClawBrand.swift
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
|
||||
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
|
||||
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
|
||||
severity: error
|
||||
|
||||
@@ -74,23 +74,30 @@ struct OpenClawLiveActivity: Widget {
|
||||
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.cyan)
|
||||
.foregroundStyle(OpenClawActivityStyle.info)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawActivityStyle.danger)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(OpenClawActivityStyle.ok)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.foregroundStyle(OpenClawActivityStyle.warn)
|
||||
}
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .cyan }
|
||||
if state.isIdle { return .green }
|
||||
return .orange
|
||||
if state.isDisconnected { return OpenClawActivityStyle.danger }
|
||||
if state.isConnecting { return OpenClawActivityStyle.info }
|
||||
if state.isIdle { return OpenClawActivityStyle.ok }
|
||||
return OpenClawActivityStyle.warn
|
||||
}
|
||||
}
|
||||
|
||||
private enum OpenClawActivityStyle {
|
||||
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
|
||||
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
|
||||
@@ -67,7 +67,7 @@ Release behavior:
|
||||
- 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.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- `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.
|
||||
@@ -102,6 +102,7 @@ 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`.
|
||||
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
|
||||
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
|
||||
- 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.
|
||||
|
||||
@@ -157,7 +158,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
|
||||
|
||||
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.
|
||||
|
||||
@@ -243,6 +244,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- 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.
|
||||
@@ -259,7 +261,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
|
||||
@@ -506,7 +506,7 @@ extension AgentProTab {
|
||||
|
||||
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
|
||||
@@ -105,7 +105,7 @@ struct AgentProTab: View {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
case .ready: OpenClawBrand.info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
|
||||
@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
|
||||
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
static let uiAccent = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
|
||||
})
|
||||
}
|
||||
|
||||
static let accent = Color(uiColor: Self.uiAccent)
|
||||
static let accentHot = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
|
||||
@@ -64,6 +66,7 @@ enum OpenClawBrand {
|
||||
})
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
static let graphite = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
|
||||
|
||||
@@ -54,6 +54,8 @@ struct SettingsProTab: View {
|
||||
@State var locationStatusText: String?
|
||||
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State var notificationStatus: SettingsNotificationStatus = .checking
|
||||
@State var isRequestingNotificationAuthorization = false
|
||||
@State var showNotificationRelayDisclosure = false
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@@ -61,15 +63,18 @@ struct SettingsProTab: View {
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let onRouteChange: ((SettingsRoute?) -> Void)?
|
||||
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
onRouteChange: ((SettingsRoute?) -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.onRouteChange = onRouteChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -117,6 +122,7 @@ struct SettingsProTab: View {
|
||||
self.refreshNotificationSettings()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.applyInitialRouteIfNeeded()
|
||||
self.notifyRouteChange()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
@@ -153,6 +159,9 @@ struct SettingsProTab: View {
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
.onChange(of: self.navigationPath) { _, _ in
|
||||
self.notifyRouteChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
@@ -217,6 +226,19 @@ struct SettingsProTab: View {
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.alert("Enable OpenClaw Hosted Push Relay?", isPresented: self.$showNotificationRelayDisclosure) {
|
||||
Button("Continue") {
|
||||
self.requestNotificationAuthorizationFromSettings()
|
||||
}
|
||||
Button("Not Now", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.notificationRelayDisclosureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func openNotificationsRouteFromApprovals() {
|
||||
guard self.directRoute == nil else { return }
|
||||
self.navigationPath = [.notifications]
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
@@ -225,4 +247,12 @@ struct SettingsProTab: View {
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
|
||||
private func notifyRouteChange() {
|
||||
if let directRoute {
|
||||
self.onRouteChange?(directRoute)
|
||||
return
|
||||
}
|
||||
self.onRouteChange?(self.navigationPath.last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,15 +426,30 @@ extension SettingsProTab {
|
||||
self.openNotificationSettings()
|
||||
return
|
||||
}
|
||||
guard self.notificationStatus == .notSet else { return }
|
||||
|
||||
if PushBuildConfig.current.usesOpenClawHostedRelay {
|
||||
self.showNotificationRelayDisclosure = true
|
||||
return
|
||||
}
|
||||
self.requestNotificationAuthorizationFromSettings()
|
||||
}
|
||||
|
||||
func requestNotificationAuthorizationFromSettings() {
|
||||
guard !self.isRequestingNotificationAuthorization else { return }
|
||||
self.isRequestingNotificationAuthorization = true
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
.alert,
|
||||
.badge,
|
||||
.sound,
|
||||
])) ?? false
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
await MainActor.run {
|
||||
self.notificationStatus = granted ? .allowed : .notAllowed
|
||||
self.isRequestingNotificationAuthorization = false
|
||||
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
|
||||
guard granted, self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,6 +676,9 @@ extension SettingsProTab {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled {
|
||||
return "Live gateway requests are disabled in demo mode."
|
||||
}
|
||||
if self.notificationsNeedAttention {
|
||||
return "Foreground approvals still appear while OpenClaw is connected."
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
|
||||
}
|
||||
|
||||
@@ -700,7 +718,19 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
var approvalsDetail: String {
|
||||
self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
|
||||
if self.notificationsNeedAttention {
|
||||
return self.pendingApproval == nil ? "Notifications off" : "1 waiting, notifications off"
|
||||
}
|
||||
return self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
|
||||
}
|
||||
|
||||
var notificationsNeedAttention: Bool {
|
||||
switch self.notificationStatus {
|
||||
case .allowed, .checking:
|
||||
false
|
||||
case .notAllowed, .notSet, .unknown:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
var approvalItems: [SettingsApprovalItem] {
|
||||
@@ -771,4 +801,36 @@ extension SettingsProTab {
|
||||
var notificationActionText: String {
|
||||
self.notificationStatus.actionTitle
|
||||
}
|
||||
|
||||
var notificationStatusDetail: String {
|
||||
switch self.notificationStatus {
|
||||
case .checking:
|
||||
"Checking iOS notification permission."
|
||||
case .allowed:
|
||||
"OpenClaw can show approval prompts and event alerts when the app is not active."
|
||||
case .notAllowed:
|
||||
"Notifications have been denied. Enable them in iOS Settings."
|
||||
case .notSet:
|
||||
"Enable notifications to receive approval prompts and event alerts outside the app."
|
||||
case .unknown:
|
||||
"OpenClaw cannot determine the current notification permission state."
|
||||
}
|
||||
}
|
||||
|
||||
var notificationRelayDetail: String {
|
||||
if PushBuildConfig.current.usesOpenClawHostedRelay {
|
||||
let host = PushBuildConfig.current.relayBaseURL.flatMap {
|
||||
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
|
||||
} ?? "ios-push-relay.openclaw.ai"
|
||||
return """
|
||||
This build uses OpenClaw's hosted push relay at \(host) for notification \
|
||||
delivery data.
|
||||
"""
|
||||
}
|
||||
return "This build is not configured to use OpenClaw's hosted push relay."
|
||||
}
|
||||
|
||||
var notificationRelayDisclosureMessage: String {
|
||||
"Enabling this sends delivery data through OpenClaw's hosted push relay."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
color: OpenClawBrand.info,
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -308,15 +308,57 @@ extension SettingsProTab {
|
||||
self.detailStatusCard(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
|
||||
"Review the pending gateway action.",
|
||||
value: self.pendingApproval == nil ? "clear" : "1 waiting",
|
||||
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
detail: self.notificationsNeedAttention
|
||||
? "Out-of-app approval alerts need notification permission."
|
||||
: (self.pendingApproval == nil ? "No gateway actions are waiting for review." :
|
||||
"Review the pending gateway action."),
|
||||
value: self.notificationsNeedAttention
|
||||
? "Alerts Off"
|
||||
: (self.pendingApproval == nil ? "clear" : "1 waiting"),
|
||||
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
|
||||
(self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn))
|
||||
|
||||
if self.notificationsNeedAttention {
|
||||
self.approvalNotificationsWarningCard
|
||||
}
|
||||
|
||||
self.approvalsReviewCard
|
||||
}
|
||||
}
|
||||
|
||||
var approvalNotificationsWarningCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: "bell.slash.fill", color: OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Notifications are off")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(
|
||||
"""
|
||||
Enable Notifications to receive approval notifications while OpenClaw is not open.
|
||||
""")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if self.directRoute == nil {
|
||||
Button {
|
||||
self.openNotificationsRouteFromApprovals()
|
||||
} label: {
|
||||
Label("Open Notifications", systemImage: "bell.badge")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var approvalsReviewCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -434,7 +476,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
color: OpenClawBrand.info,
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -490,7 +532,7 @@ extension SettingsProTab {
|
||||
self.detailStatusCard(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approvals and event alerts from OpenClaw.",
|
||||
detail: self.notificationStatusDetail,
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatus.color)
|
||||
|
||||
@@ -506,10 +548,25 @@ extension SettingsProTab {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
|
||||
|
||||
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
|
||||
Text(self.notificationStatusDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "network")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(width: 22, height: 22)
|
||||
Text(self.notificationRelayDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
@@ -983,7 +1040,7 @@ extension SettingsProTab {
|
||||
|
||||
func settingsSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
|
||||
@@ -89,28 +89,48 @@ enum SettingsNotificationStatus: Equatable {
|
||||
var text: String {
|
||||
switch self {
|
||||
case .checking: "Checking"
|
||||
case .allowed: "Allowed"
|
||||
case .notAllowed: "Not Allowed"
|
||||
case .notSet: "Not Set"
|
||||
case .allowed: "Enabled"
|
||||
case .notAllowed: "Denied"
|
||||
case .notSet: "Not Enabled"
|
||||
case .unknown: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
var actionTitle: String {
|
||||
switch self {
|
||||
case .notSet, .checking:
|
||||
"Request Access"
|
||||
case .allowed, .notAllowed, .unknown:
|
||||
"Open System Settings"
|
||||
case .notSet:
|
||||
"Enable Notifications"
|
||||
case .checking:
|
||||
"Checking"
|
||||
case .allowed:
|
||||
"Manage in iOS Settings"
|
||||
case .notAllowed, .unknown:
|
||||
"Open iOS Settings"
|
||||
}
|
||||
}
|
||||
|
||||
var actionIcon: String {
|
||||
self == .allowed ? "gear" : "bell.badge"
|
||||
switch self {
|
||||
case .allowed:
|
||||
"gear"
|
||||
case .notAllowed, .unknown:
|
||||
"gear.badge"
|
||||
case .checking:
|
||||
"hourglass"
|
||||
case .notSet:
|
||||
"bell.badge"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
self == .allowed ? OpenClawBrand.ok : .secondary
|
||||
switch self {
|
||||
case .allowed:
|
||||
OpenClawBrand.ok
|
||||
case .notAllowed, .unknown:
|
||||
OpenClawBrand.warn
|
||||
case .checking, .notSet:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
var shouldOpenNotificationSettings: Bool {
|
||||
@@ -121,6 +141,10 @@ enum SettingsNotificationStatus: Equatable {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var allowsNotifications: Bool {
|
||||
self == .allowed
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
|
||||
@@ -2,11 +2,14 @@ import SwiftUI
|
||||
|
||||
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
let suppressedApprovalID: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if let prompt = self.appModel.pendingExecApprovalPrompt {
|
||||
if let prompt = self.appModel.pendingExecApprovalPrompt,
|
||||
prompt.id != self.suppressedApprovalID
|
||||
{
|
||||
ZStack {
|
||||
Color.black.opacity(0.38)
|
||||
.ignoresSafeArea()
|
||||
@@ -58,7 +61,7 @@ private struct ExecApprovalPromptCard: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Exec approval required")
|
||||
.font(.headline)
|
||||
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
|
||||
Text("Review this exec request before continuing. Your decision will be sent back to the gateway.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -87,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
|
||||
if let errorText = self.normalized(self.errorText) {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
}
|
||||
|
||||
if self.isResolving {
|
||||
@@ -188,7 +191,7 @@ private struct ExecApprovalPromptMetadataRow: View {
|
||||
}
|
||||
|
||||
extension View {
|
||||
func execApprovalPromptDialog() -> some View {
|
||||
self.modifier(ExecApprovalPromptDialogModifier())
|
||||
func execApprovalPromptDialog(suppressedApprovalID: String? = nil) -> some View {
|
||||
self.modifier(ExecApprovalPromptDialogModifier(suppressedApprovalID: suppressedApprovalID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct NotificationPermissionGuidanceDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
let openNotifications: (String) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if let prompt = self.appModel.pendingNotificationPermissionGuidancePrompt {
|
||||
ZStack {
|
||||
Color.black.opacity(0.38)
|
||||
.ignoresSafeArea()
|
||||
|
||||
NotificationPermissionGuidanceCard(
|
||||
onOpenNotifications: {
|
||||
let approvalId = prompt.approvalId
|
||||
self.appModel.dismissNotificationPermissionGuidancePrompt(
|
||||
suppressFuture: false)
|
||||
self.openNotifications(approvalId)
|
||||
},
|
||||
onDismiss: {
|
||||
self.appModel.dismissNotificationPermissionGuidancePrompt(
|
||||
suppressFuture: false)
|
||||
},
|
||||
onSuppressFuture: {
|
||||
self.appModel.dismissNotificationPermissionGuidancePrompt(
|
||||
suppressFuture: true)
|
||||
})
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: 460)
|
||||
.transition(.scale(scale: 0.98).combined(with: .opacity))
|
||||
}
|
||||
.zIndex(2)
|
||||
.id(prompt.id)
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
.easeInOut(duration: 0.18),
|
||||
value: self.appModel.pendingNotificationPermissionGuidancePrompt?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationPermissionGuidanceCard: View {
|
||||
let onOpenNotifications: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
let onSuppressFuture: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Notifications are off")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"""
|
||||
Exec approvals can only be reviewed while OpenClaw is open and connected.
|
||||
|
||||
Enable Notifications to receive approval notifications while OpenClaw is not open.
|
||||
""")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
self.onOpenNotifications()
|
||||
} label: {
|
||||
Text("Open Notifications Settings")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(role: .cancel) {
|
||||
self.onDismiss()
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
self.onSuppressFuture()
|
||||
} label: {
|
||||
Text("Don't show again")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(18)
|
||||
.proPanelSurface(tint: OpenClawBrand.warn, radius: 20, isProminent: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func notificationPermissionGuidanceDialog(openNotifications: @escaping (String) -> Void) -> some View {
|
||||
self.modifier(NotificationPermissionGuidanceDialogModifier(openNotifications: openNotifications))
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,12 @@
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushProofPolicy</key>
|
||||
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushRelayProfile</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
|
||||
@@ -87,6 +87,11 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationPermissionGuidancePrompt: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
private enum ExecApprovalResolutionOutcome {
|
||||
case resolved
|
||||
case stale
|
||||
@@ -100,6 +105,8 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
|
||||
"notifications.execApprovalGuidance.suppressed"
|
||||
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")
|
||||
@@ -160,6 +167,7 @@ final class NodeAppModel {
|
||||
private(set) var pendingExecApprovalPromptResolving: Bool = false
|
||||
private(set) var pendingExecApprovalPromptErrorText: String?
|
||||
private var pendingExecApprovalPromptRequestGeneration: Int = 0
|
||||
private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt?
|
||||
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||
@@ -921,6 +929,7 @@ final class NodeAppModel {
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
case ExecApprovalNotificationBridge.requestedKind:
|
||||
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
||||
await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: approvalId)
|
||||
await self.presentExecApprovalNotificationPrompt(
|
||||
ExecApprovalNotificationPrompt(approvalId: approvalId))
|
||||
case ExecApprovalNotificationBridge.resolvedKind:
|
||||
@@ -1359,8 +1368,8 @@ final class NodeAppModel {
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
|
||||
}
|
||||
|
||||
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
|
||||
guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else {
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard Self.isNotificationAuthorizationAllowed(status) else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
@@ -1412,9 +1421,18 @@ final class NodeAppModel {
|
||||
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
|
||||
}
|
||||
|
||||
let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
|
||||
let shouldSpeak = params.speak ?? true
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status)
|
||||
if !notificationsAllowed, !shouldSpeak {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
|
||||
}
|
||||
|
||||
let messageId = UUID().uuidString
|
||||
if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral {
|
||||
if notificationsAllowed {
|
||||
let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "OpenClaw"
|
||||
@@ -1435,7 +1453,7 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
if params.speak ?? true {
|
||||
if shouldSpeak {
|
||||
let toSpeak = text
|
||||
Task { @MainActor in
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
|
||||
@@ -1447,26 +1465,6 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
}
|
||||
|
||||
private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard status == .notDetermined else { return status }
|
||||
|
||||
// Avoid hanging invoke requests if the permission prompt is never answered.
|
||||
_ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
|
||||
let updatedStatus = await self.notificationAuthorizationStatus()
|
||||
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
|
||||
// Refresh APNs registration immediately after the first permission grant so the
|
||||
// gateway can receive a push registration without requiring an app relaunch.
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
return updatedStatus
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
|
||||
await notificationCenter.authorizationStatus()
|
||||
@@ -1490,6 +1488,29 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(approvalId: String) async {
|
||||
guard !self.execApprovalNotificationGuidanceSuppressed else { return }
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard !Self.isNotificationAuthorizationAllowed(status) else { return }
|
||||
self.pendingNotificationPermissionGuidancePrompt =
|
||||
NotificationPermissionGuidancePrompt(approvalId: approvalId)
|
||||
}
|
||||
|
||||
var execApprovalNotificationGuidanceSuppressed: Bool {
|
||||
UserDefaults.standard.bool(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
||||
}
|
||||
|
||||
func dismissNotificationPermissionGuidancePrompt(suppressFuture: Bool) {
|
||||
if suppressFuture {
|
||||
UserDefaults.standard.set(true, forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
||||
}
|
||||
self.pendingNotificationPermissionGuidancePrompt = nil
|
||||
}
|
||||
|
||||
func resetExecApprovalNotificationGuidanceSuppression() {
|
||||
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
||||
}
|
||||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
||||
@@ -2362,10 +2383,6 @@ extension NodeAppModel {
|
||||
nodeOptions: nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
// QR bootstrap onboarding should surface the system notification permission
|
||||
// prompt immediately so visible APNs alerts work without a second manual step.
|
||||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||
}
|
||||
|
||||
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
@@ -3943,11 +3960,15 @@ extension NodeAppModel {
|
||||
let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
|
||||
let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
|
||||
let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
|
||||
guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
|
||||
let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID
|
||||
let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID
|
||||
guard hadApprovalSurface || hadGuidancePrompt else {
|
||||
return
|
||||
}
|
||||
|
||||
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
|
||||
if hadApprovalSurface {
|
||||
await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
|
||||
}
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
}
|
||||
|
||||
@@ -4428,10 +4449,17 @@ extension NodeAppModel {
|
||||
|
||||
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.clearNotificationPermissionGuidancePromptIfMatches(normalizedApprovalID)
|
||||
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return }
|
||||
self.pendingNotificationPermissionGuidancePrompt = nil
|
||||
}
|
||||
|
||||
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
@@ -5137,6 +5165,20 @@ extension NodeAppModel {
|
||||
self.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? {
|
||||
self.pendingNotificationPermissionGuidancePrompt
|
||||
}
|
||||
|
||||
func _debug_presentNotificationPermissionGuidancePromptForScreenshot() {
|
||||
self.resetExecApprovalNotificationGuidanceSuppression()
|
||||
self.pendingNotificationPermissionGuidancePrompt =
|
||||
NotificationPermissionGuidancePrompt(approvalId: "screenshot-exec-approval")
|
||||
}
|
||||
|
||||
func _test_resetExecApprovalNotificationGuidanceSuppression() {
|
||||
self.resetExecApprovalNotificationGuidanceSuppression()
|
||||
}
|
||||
|
||||
func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
|
||||
self.appendPendingWatchExecApprovalRecoveryID(approvalId)
|
||||
}
|
||||
@@ -5270,24 +5312,6 @@ extension NodeAppModel {
|
||||
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.restartGatewaySessionsAfterForegroundStaleConnection()
|
||||
}
|
||||
|
||||
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
stableID: "test-gateway",
|
||||
token: nil,
|
||||
password: nil,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: nil),
|
||||
sessionBox: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
|
||||
|
||||
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connected")
|
||||
|
||||
@@ -409,7 +409,7 @@ enum WatchPromptNotificationBridge {
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty || !body.isEmpty else { return }
|
||||
guard await self.requestNotificationAuthorizationIfNeeded() else { return }
|
||||
guard await self.isNotificationAuthorizationAllowed() else { return }
|
||||
|
||||
let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in
|
||||
let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -516,29 +516,10 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestNotificationAuthorizationIfNeeded() async -> Bool {
|
||||
private static func isNotificationAuthorizationAllowed() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let status = await self.notificationAuthorizationStatus(center: center)
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .notDetermined:
|
||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
if !granted { return false }
|
||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||
// Refresh APNs registration immediately after the first permission grant so the
|
||||
// gateway can receive a push registration without requiring an app relaunch.
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
return self.isAuthorizationStatusAllowed(updatedStatus)
|
||||
case .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
return self.isAuthorizationStatusAllowed(status)
|
||||
}
|
||||
|
||||
private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool {
|
||||
@@ -635,6 +616,9 @@ struct OpenClawApp: App {
|
||||
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
|
||||
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
|
||||
appModel.enterScreenshotFixtureMode()
|
||||
if Self.screenshotNotificationGuidanceEnabled {
|
||||
appModel._debug_presentNotificationPermissionGuidancePromptForScreenshot()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
@@ -648,6 +632,7 @@ struct OpenClawApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootTabs()
|
||||
.tint(OpenClawBrand.accent)
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
@@ -686,6 +671,14 @@ struct OpenClawApp: App {
|
||||
#endif
|
||||
}
|
||||
|
||||
private static var screenshotNotificationGuidanceEnabled: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-notification-guidance")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
@@ -694,6 +687,7 @@ struct OpenClawApp: App {
|
||||
.flatMap(\.windows)
|
||||
.forEach { window in
|
||||
window.overrideUserInterfaceStyle = style
|
||||
window.tintColor = OpenClawBrand.uiAccent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/ios/Sources/OpenClawAppAttest.entitlements
Normal file
14
apps/ios/Sources/OpenClawAppAttest.entitlements
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||
<string>$(OPENCLAW_APP_ATTEST_ENVIRONMENT)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -15,13 +15,43 @@ enum PushAPNsEnvironment: String {
|
||||
case production
|
||||
}
|
||||
|
||||
enum PushRelayProfile: String {
|
||||
case production
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
enum PushProofPolicy: String {
|
||||
case appleStrict
|
||||
case appleDevelopment
|
||||
case internalSimulator
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
let apnsEnvironment: PushAPNsEnvironment
|
||||
let relayProfile: PushRelayProfile
|
||||
let proofPolicy: PushProofPolicy
|
||||
|
||||
static let current = PushBuildConfig()
|
||||
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
|
||||
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
|
||||
|
||||
var usesOpenClawHostedRelay: Bool {
|
||||
guard self.transport == .relay, self.distribution == .official else { return false }
|
||||
guard let relayBaseURL = self.relayBaseURL,
|
||||
let components = URLComponents(url: relayBaseURL, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == "https"
|
||||
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
|
||||
.contains(components.host?.lowercased() ?? "")
|
||||
&& components.user == nil
|
||||
&& components.password == nil
|
||||
}
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
self.transport = Self.readEnum(
|
||||
@@ -36,6 +66,14 @@ struct PushBuildConfig {
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayProfile = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushRelayProfile",
|
||||
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
|
||||
self.proofPolicy = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushProofPolicy",
|
||||
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
@@ -63,9 +101,24 @@ struct PushBuildConfig {
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return T(rawValue: trimmed) ?? fallback
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
|
||||
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
|
||||
apnsEnvironment == .production ? .production : .deviceSandbox
|
||||
}
|
||||
|
||||
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
.appleStrict
|
||||
case .deviceSandbox:
|
||||
.appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
.internalSimulator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ actor PushRegistrationManager {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
}
|
||||
guard self.buildConfig.apnsEnvironment == .production else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushAPNsEnvironment=production")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment,
|
||||
proofPolicy: self.buildConfig.proofPolicy)
|
||||
guard let relayClient = self.relayClient else {
|
||||
throw PushRelayError.relayBaseURLMissing
|
||||
}
|
||||
@@ -96,6 +96,9 @@ actor PushRegistrationManager {
|
||||
stored.installationId == installationId,
|
||||
stored.gatewayDeviceId == gatewayIdentity.deviceId,
|
||||
stored.relayOrigin == relayOrigin,
|
||||
stored.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
|
||||
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
|
||||
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
|
||||
stored.lastAPNsTokenHashHex == tokenHashHex,
|
||||
!Self.isExpired(stored.relayHandleExpiresAtMs)
|
||||
{
|
||||
@@ -112,14 +115,16 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
let response = try await relayClient.register(
|
||||
let response = try await relayClient.register(PushRelayRegistrationInput(
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
appVersion: DeviceInfoHelper.appVersion(),
|
||||
environment: self.buildConfig.apnsEnvironment,
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
proofPolicy: self.buildConfig.proofPolicy,
|
||||
distribution: self.buildConfig.distribution,
|
||||
apnsTokenHex: apnsTokenHex,
|
||||
gatewayIdentity: gatewayIdentity)
|
||||
gatewayIdentity: gatewayIdentity))
|
||||
let registrationState = PushRelayRegistrationStore.RegistrationState(
|
||||
relayHandle: response.relayHandle,
|
||||
sendGrant: response.sendGrant,
|
||||
@@ -129,7 +134,10 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
|
||||
lastAPNsTokenHashHex: tokenHashHex,
|
||||
installationId: installationId,
|
||||
lastTransport: self.buildConfig.transport.rawValue)
|
||||
lastTransport: self.buildConfig.transport.rawValue,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
relayProfile: self.buildConfig.relayProfile.rawValue,
|
||||
proofPolicy: self.buildConfig.proofPolicy.rawValue)
|
||||
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||
return try Self.encodePayload(
|
||||
RelayGatewayPushRegistrationPayload(
|
||||
@@ -151,6 +159,30 @@ actor PushRegistrationManager {
|
||||
return expiresAtMs <= nowMs + 60000
|
||||
}
|
||||
|
||||
private static func validateRelayContract(
|
||||
relayProfile: PushRelayProfile,
|
||||
apnsEnvironment: PushAPNsEnvironment,
|
||||
proofPolicy: PushProofPolicy)
|
||||
throws {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"production relay profile requires production APNs and appleStrict proof")
|
||||
}
|
||||
case .deviceSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
|
||||
}
|
||||
case .simulatorSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func sha256Hex(_ value: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(value.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
@@ -40,6 +40,9 @@ private struct PushRelayRegisterSignedPayload: Encodable {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
@@ -63,12 +66,16 @@ private struct PushRelayRegisterRequest: Encodable {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
var apnsToken: String
|
||||
var appAttest: PushRelayAppAttestPayload
|
||||
var receipt: PushRelayReceiptPayload
|
||||
var appAttest: PushRelayAppAttestPayload?
|
||||
var receipt: PushRelayReceiptPayload?
|
||||
var simulatorProof: PushRelaySimulatorProofPayload?
|
||||
}
|
||||
|
||||
struct PushRelayRegisterResponse: Decodable {
|
||||
@@ -93,23 +100,34 @@ private struct PushRelayAppAttestProof {
|
||||
var signedPayloadBase64: String
|
||||
}
|
||||
|
||||
private struct PushRelaySimulatorProofPayload: Encodable {
|
||||
var signedPayloadBase64: String
|
||||
var hmacSha256Base64Url: String
|
||||
}
|
||||
|
||||
private final class PushRelayAppAttestService {
|
||||
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
|
||||
func createProof(
|
||||
challenge: String,
|
||||
signedPayload: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof {
|
||||
let service = DCAppAttestService.shared
|
||||
guard service.isSupported else {
|
||||
throw PushRelayError.unsupportedAppAttest
|
||||
}
|
||||
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service)
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
|
||||
let attestationObject = try await self.attestKeyIfNeeded(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
challenge: challenge)
|
||||
challenge: challenge,
|
||||
scope: scope)
|
||||
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||
let assertion = try await self.generateAssertion(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
signedPayloadHash: signedPayloadHash)
|
||||
signedPayloadHash: signedPayloadHash,
|
||||
scope: scope)
|
||||
|
||||
return PushRelayAppAttestProof(
|
||||
keyId: keyID,
|
||||
@@ -119,21 +137,27 @@ private final class PushRelayAppAttestService {
|
||||
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||
}
|
||||
|
||||
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
|
||||
private func loadOrCreateKeyID(
|
||||
using service: DCAppAttestService,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
let keyID = try await service.generateKey()
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
|
||||
return keyID
|
||||
}
|
||||
|
||||
private func attestKeyIfNeeded(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
challenge: String)
|
||||
challenge: String,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String? {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
|
||||
return nil
|
||||
}
|
||||
let challengeData = Data(challenge.utf8)
|
||||
@@ -142,20 +166,21 @@ private final class PushRelayAppAttestService {
|
||||
// Apple treats App Attest key attestation as a one-time operation. Save the
|
||||
// attested marker immediately so later receipt/network failures do not cause a
|
||||
// permanently broken re-attestation loop on the same key.
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID, scope: scope)
|
||||
return attestation.base64EncodedString()
|
||||
}
|
||||
|
||||
private func generateAssertion(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
signedPayloadHash: Data)
|
||||
signedPayloadHash: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> Data {
|
||||
do {
|
||||
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||
} catch {
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -190,6 +215,47 @@ private final class PushRelayReceiptProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PushRelaySimulatorProofProvider {
|
||||
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
|
||||
#if targetEnvironment(simulator)
|
||||
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!secret.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
|
||||
}
|
||||
let signedPayloadBase64 = signedPayload.base64EncodedString()
|
||||
let signature = HMAC<SHA256>.authenticationCode(
|
||||
for: Data(signedPayloadBase64.utf8),
|
||||
using: SymmetricKey(data: Data(secret.utf8)))
|
||||
return PushRelaySimulatorProofPayload(
|
||||
signedPayloadBase64: signedPayloadBase64,
|
||||
hmacSha256Base64Url: Self.base64URL(Data(signature)))
|
||||
#else
|
||||
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
struct PushRelayRegistrationInput {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var appVersion: String
|
||||
var environment: PushAPNsEnvironment
|
||||
var relayProfile: PushRelayProfile
|
||||
var proofPolicy: PushProofPolicy
|
||||
var distribution: PushDistributionMode
|
||||
var apnsTokenHex: String
|
||||
var gatewayIdentity: PushRelayGatewayIdentity
|
||||
}
|
||||
|
||||
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||
final class PushRelayClient: @unchecked Sendable {
|
||||
private let baseURL: URL
|
||||
@@ -198,6 +264,7 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
private let jsonEncoder = JSONEncoder()
|
||||
private let appAttest = PushRelayAppAttestService()
|
||||
private let receiptProvider = PushRelayReceiptProvider()
|
||||
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
|
||||
|
||||
init(baseURL: URL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
@@ -208,46 +275,57 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
Self.normalizeBaseURLString(self.baseURL)
|
||||
}
|
||||
|
||||
func register(
|
||||
installationId: String,
|
||||
bundleId: String,
|
||||
appVersion: String,
|
||||
environment: PushAPNsEnvironment,
|
||||
distribution: PushDistributionMode,
|
||||
apnsTokenHex: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity)
|
||||
async throws -> PushRelayRegisterResponse {
|
||||
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
|
||||
let challenge = try await self.fetchChallenge()
|
||||
let signedPayload = PushRelayRegisterSignedPayload(
|
||||
challengeId: challenge.challengeId,
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
environment: environment.rawValue,
|
||||
distribution: distribution.rawValue,
|
||||
gateway: gatewayIdentity,
|
||||
appVersion: appVersion,
|
||||
apnsToken: apnsTokenHex)
|
||||
installationId: input.installationId,
|
||||
bundleId: input.bundleId,
|
||||
environment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue,
|
||||
distribution: input.distribution.rawValue,
|
||||
gateway: input.gatewayIdentity,
|
||||
appVersion: input.appVersion,
|
||||
apnsToken: input.apnsTokenHex)
|
||||
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||
let appAttest = try await self.appAttest.createProof(
|
||||
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
|
||||
relayOrigin: self.normalizedBaseURLString,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue)
|
||||
let appAttest = try await self.createAppAttestProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
challenge: challenge.challenge,
|
||||
signedPayload: signedPayloadData)
|
||||
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
|
||||
signedPayloadData: signedPayloadData,
|
||||
scope: appAttestScope)
|
||||
let receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
|
||||
let simulatorProof = try self.createSimulatorProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
signedPayloadData: signedPayloadData)
|
||||
let requestBody = PushRelayRegisterRequest(
|
||||
challengeId: signedPayload.challengeId,
|
||||
installationId: signedPayload.installationId,
|
||||
bundleId: signedPayload.bundleId,
|
||||
environment: signedPayload.environment,
|
||||
relayProfile: signedPayload.relayProfile,
|
||||
apnsEnvironment: signedPayload.apnsEnvironment,
|
||||
proofPolicy: signedPayload.proofPolicy,
|
||||
distribution: signedPayload.distribution,
|
||||
gateway: signedPayload.gateway,
|
||||
appVersion: signedPayload.appVersion,
|
||||
apnsToken: signedPayload.apnsToken,
|
||||
appAttest: PushRelayAppAttestPayload(
|
||||
keyId: appAttest.keyId,
|
||||
attestationObject: appAttest.attestationObject,
|
||||
assertion: appAttest.assertion,
|
||||
clientDataHash: appAttest.clientDataHash,
|
||||
signedPayloadBase64: appAttest.signedPayloadBase64),
|
||||
receipt: PushRelayReceiptPayload(base64: receiptBase64))
|
||||
appAttest: appAttest.map {
|
||||
PushRelayAppAttestPayload(
|
||||
keyId: $0.keyId,
|
||||
attestationObject: $0.attestationObject,
|
||||
assertion: $0.assertion,
|
||||
clientDataHash: $0.clientDataHash,
|
||||
signedPayloadBase64: $0.signedPayloadBase64)
|
||||
},
|
||||
receipt: receipt,
|
||||
simulatorProof: simulatorProof)
|
||||
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/register")
|
||||
var request = URLRequest(url: endpoint)
|
||||
@@ -262,8 +340,8 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
if status == 401 {
|
||||
// If the relay rejects registration, drop local App Attest state so the next
|
||||
// attempt re-attests instead of getting stuck without an attestation object.
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: appAttestScope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
|
||||
}
|
||||
throw PushRelayError.requestFailed(
|
||||
status: status,
|
||||
@@ -272,6 +350,43 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
return try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func createAppAttestProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
challenge: String,
|
||||
signedPayloadData: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof? {
|
||||
guard proofPolicy != .internalSimulator else { return nil }
|
||||
return try await self.appAttest.createProof(
|
||||
challenge: challenge,
|
||||
signedPayload: signedPayloadData,
|
||||
scope: scope)
|
||||
}
|
||||
|
||||
private func createReceiptIfNeeded(
|
||||
proofPolicy: PushProofPolicy)
|
||||
async throws -> PushRelayReceiptPayload? {
|
||||
switch proofPolicy {
|
||||
case .appleStrict:
|
||||
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
|
||||
case .appleDevelopment:
|
||||
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
|
||||
return nil
|
||||
}
|
||||
return PushRelayReceiptPayload(base64: receiptBase64)
|
||||
case .internalSimulator:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createSimulatorProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
signedPayloadData: Data)
|
||||
throws -> PushRelaySimulatorProofPayload? {
|
||||
guard proofPolicy == .internalSimulator else { return nil }
|
||||
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
|
||||
var request = URLRequest(url: endpoint)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
private struct StoredPushRelayRegistrationState: Codable {
|
||||
@@ -10,6 +11,9 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String?
|
||||
var relayProfile: String?
|
||||
var proofPolicy: String?
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
@@ -18,6 +22,13 @@ enum PushRelayRegistrationStore {
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
|
||||
struct AppAttestScope {
|
||||
var relayOrigin: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
struct RegistrationState: Codable {
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
@@ -28,6 +39,9 @@ enum PushRelayRegistrationStore {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
static func loadRegistrationState() -> RegistrationState? {
|
||||
@@ -48,7 +62,10 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: decoded.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
|
||||
installationId: decoded.installationId,
|
||||
lastTransport: decoded.lastTransport)
|
||||
lastTransport: decoded.lastTransport,
|
||||
apnsEnvironment: decoded.apnsEnvironment ?? "production",
|
||||
relayProfile: decoded.relayProfile ?? "production",
|
||||
proofPolicy: decoded.proofPolicy ?? "appleStrict")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -62,7 +79,10 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: state.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
|
||||
installationId: state.installationId,
|
||||
lastTransport: state.lastTransport)
|
||||
lastTransport: state.lastTransport,
|
||||
apnsEnvironment: state.apnsEnvironment,
|
||||
relayProfile: state.relayProfile,
|
||||
proofPolicy: state.proofPolicy)
|
||||
guard let data = try? JSONEncoder().encode(stored),
|
||||
let raw = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
@@ -71,37 +91,66 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
|
||||
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAppAttestKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
|
||||
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
static func loadAttestedKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
|
||||
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAttestedKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAttestedKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
|
||||
let raw = [
|
||||
scope.relayOrigin,
|
||||
scope.apnsEnvironment,
|
||||
scope.relayProfile,
|
||||
scope.proofPolicy,
|
||||
].joined(separator: "\n")
|
||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
// A relay sees an App Attest key as attested only after receiving that
|
||||
// key's attestation object, so keep key state isolated per relay context.
|
||||
return "\(baseAccount)-\(digest)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ struct RootTabs: View {
|
||||
AppAppearancePreference.system.rawValue
|
||||
@State private var selectedTab: AppTab = Self.initialTab
|
||||
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
||||
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
|
||||
@State private var selectedSettingsRouteRequestID: Int = 0
|
||||
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
||||
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
||||
@State private var isSidebarDrawerLayout: Bool = false
|
||||
@@ -39,6 +41,7 @@ struct RootTabs: View {
|
||||
@State private var didApplyInitialAppearance: Bool = false
|
||||
@State private var didApplyInitialChatSession: Bool = false
|
||||
@State private var handledGatewaySetupRequestID: Int = 0
|
||||
@State private var suppressedExecApprovalPromptIDForNotificationSettings: String?
|
||||
|
||||
private static var initialTab: AppTab {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
@@ -161,8 +164,10 @@ struct RootTabs: View {
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
|
||||
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
|
||||
SettingsProTab(
|
||||
initialRoute: self.selectedSettingsRoute,
|
||||
onRouteChange: self.handleSettingsRouteChange)
|
||||
.id(self.settingsTabViewID)
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
@@ -235,7 +240,7 @@ struct RootTabs: View {
|
||||
|
||||
private var sidebarDetailShell: some View {
|
||||
self.sidebarDetail
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
.id(self.sidebarDetailShellID)
|
||||
}
|
||||
|
||||
private var sidebarColumn: some View {
|
||||
@@ -463,11 +468,21 @@ struct RootTabs: View {
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
||||
case .settings:
|
||||
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
if let selectedSettingsRoute {
|
||||
SettingsProTab(
|
||||
directRoute: selectedSettingsRoute,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
onRouteChange: self.handleSettingsRouteChange)
|
||||
} else {
|
||||
SettingsProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
onRouteChange: self.handleSettingsRouteChange)
|
||||
}
|
||||
case .gateway:
|
||||
SettingsProTab(
|
||||
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
onRouteChange: self.handleSettingsRouteChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +507,21 @@ struct RootTabs: View {
|
||||
return UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
|
||||
private var sidebarDetailShellID: String {
|
||||
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "root"
|
||||
return "\(self.selectedSidebarDestination.id):\(routeID):\(self.selectedSettingsRouteRequestID)"
|
||||
}
|
||||
|
||||
private var settingsTabViewID: String {
|
||||
let routeID = self.selectedSettingsRoute.map { "\($0)" } ?? "settings"
|
||||
return "\(routeID):\(self.selectedSettingsRouteRequestID)"
|
||||
}
|
||||
|
||||
private var activeExecApprovalPromptSuppressionID: String? {
|
||||
guard self.selectedTab == .settings, self.selectedSettingsRoute == .notifications else { return nil }
|
||||
return self.suppressedExecApprovalPromptIDForNotificationSettings
|
||||
}
|
||||
|
||||
private var shouldCollapseSidebarAfterSelection: Bool {
|
||||
Self.shouldCollapseSidebarAfterSelection(
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
@@ -705,6 +735,11 @@ struct RootTabs: View {
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.maybeOpenSettingsForGatewaySetup()
|
||||
}
|
||||
.onChange(of: self.appModel.pendingExecApprovalPrompt?.id) { _, newValue in
|
||||
if newValue != self.suppressedExecApprovalPromptIDForNotificationSettings {
|
||||
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rootPresentation(_ content: some View) -> some View {
|
||||
@@ -742,7 +777,12 @@ struct RootTabs: View {
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog()
|
||||
.execApprovalPromptDialog(
|
||||
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
|
||||
.notificationPermissionGuidanceDialog(openNotifications: { approvalId in
|
||||
self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId
|
||||
self.selectSettingsRoute(.notifications)
|
||||
})
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
@@ -874,9 +914,15 @@ struct RootTabs: View {
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
}
|
||||
|
||||
extension RootTabs {
|
||||
private func selectSidebarDestination(_ destination: SidebarDestination) {
|
||||
if destination.settingsRoute != .notifications {
|
||||
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
|
||||
}
|
||||
self.selectedSidebarDestination = destination
|
||||
self.selectedSettingsRoute = destination.settingsRoute
|
||||
self.selectedTab = destination.appTab
|
||||
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
@@ -884,6 +930,31 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSettingsRoute(_ route: SettingsRoute) {
|
||||
if route != .notifications {
|
||||
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
|
||||
}
|
||||
self.selectedSettingsRoute = route
|
||||
self.selectedSettingsRouteRequestID &+= 1
|
||||
self.selectedSidebarDestination = .settings
|
||||
self.selectedTab = .settings
|
||||
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSettingsRouteChange(_ route: SettingsRoute?) {
|
||||
guard route != .notifications else { return }
|
||||
if route == nil {
|
||||
self.selectedSettingsRoute = nil
|
||||
if self.selectedTab == .settings {
|
||||
self.selectedSidebarDestination = .settings
|
||||
}
|
||||
}
|
||||
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
|
||||
}
|
||||
|
||||
private func showSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
|
||||
@@ -16,7 +16,6 @@ enum NotificationAuthorizationStatus {
|
||||
|
||||
protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
|
||||
@@ -48,10 +47,6 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
try await self.center.requestAuthorization(options: options)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.center.add(request) { error in
|
||||
|
||||
@@ -1008,39 +1008,60 @@ final class TalkModeManager: NSObject {
|
||||
self.logger.info(
|
||||
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)")
|
||||
let runId = try await self.sendChat(prompt, gateway: gateway)
|
||||
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)")
|
||||
let ack = try await self.sendChat(prompt, gateway: gateway)
|
||||
let runId = ack.runId
|
||||
let normalizedStatus = Self.normalizedChatSendStatus(ack.status)
|
||||
self.logger.info(
|
||||
"chat.send ok runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId) status=\(normalizedStatus)")
|
||||
if Self.isTerminalChatSendFailure(ack.status) {
|
||||
self.statusText = normalizedStatus == "error" ? "Chat error" : "Aborted"
|
||||
self.logger.warning(
|
||||
"chat.send terminal ack runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
|
||||
GatewayDiagnostics.log(
|
||||
"talk: chat.send terminal ack runId=\(runId) status=\(normalizedStatus)")
|
||||
if restartAfter {
|
||||
await self.start()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let shouldIncremental = self.shouldUseIncrementalTTS()
|
||||
var streamingTask: Task<Void, Never>?
|
||||
if shouldIncremental {
|
||||
self.resetIncrementalSpeech()
|
||||
streamingTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.streamAssistant(runId: runId, gateway: gateway)
|
||||
let completion: ChatCompletionResult
|
||||
if Self.isTerminalChatSendSuccess(ack.status) {
|
||||
GatewayDiagnostics.log("talk: chat.send terminal ok runId=\(runId); using history fallback")
|
||||
completion = ChatCompletionResult(state: .final, assistantText: nil)
|
||||
} else {
|
||||
if shouldIncremental {
|
||||
self.resetIncrementalSpeech()
|
||||
streamingTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.streamAssistant(runId: runId, gateway: gateway)
|
||||
}
|
||||
}
|
||||
completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
|
||||
if completion.state == .timeout {
|
||||
self.logger.warning(
|
||||
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
|
||||
} else if completion.state == .aborted {
|
||||
self.statusText = "Aborted"
|
||||
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
|
||||
streamingTask?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
await self.start()
|
||||
return
|
||||
} else if completion.state == .error {
|
||||
self.statusText = "Chat error"
|
||||
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
|
||||
streamingTask?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
await self.start()
|
||||
return
|
||||
}
|
||||
}
|
||||
let completion = await waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
|
||||
if completion.state == .timeout {
|
||||
self.logger.warning(
|
||||
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
|
||||
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
|
||||
} else if completion.state == .aborted {
|
||||
self.statusText = "Aborted"
|
||||
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
|
||||
streamingTask?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
await self.start()
|
||||
return
|
||||
} else if completion.state == .error {
|
||||
self.statusText = "Chat error"
|
||||
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
|
||||
streamingTask?.cancel()
|
||||
await self.finishIncrementalSpeech()
|
||||
await self.start()
|
||||
return
|
||||
}
|
||||
|
||||
var assistantText = completion.assistantText
|
||||
@@ -1053,7 +1074,7 @@ final class TalkModeManager: NSObject {
|
||||
if assistantText == nil {
|
||||
assistantText = try await self.waitForAssistantTextFromHistory(
|
||||
gateway: gateway,
|
||||
since: startedAt,
|
||||
since: Self.chatSendHistorySince(response: ack, startedAt: startedAt),
|
||||
timeoutSeconds: completion.state == .final ? 12 : 25)
|
||||
}
|
||||
guard let assistantText else {
|
||||
@@ -1343,8 +1364,27 @@ final class TalkModeManager: NSObject {
|
||||
var assistantText: String?
|
||||
}
|
||||
|
||||
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
|
||||
struct SendResponse: Decodable { let runId: String }
|
||||
private static func normalizedChatSendStatus(_ status: String) -> String {
|
||||
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
|
||||
self.normalizedChatSendStatus(status) == "ok"
|
||||
}
|
||||
|
||||
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
|
||||
let normalized = self.normalizedChatSendStatus(status)
|
||||
return normalized == "timeout" || normalized == "error"
|
||||
}
|
||||
|
||||
private static func chatSendHistorySince(
|
||||
response: OpenClawChatSendResponse,
|
||||
startedAt: Double) -> Double?
|
||||
{
|
||||
self.isTerminalChatSendSuccess(response.status) ? nil : startedAt
|
||||
}
|
||||
|
||||
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> OpenClawChatSendResponse {
|
||||
let payload: [String: Any] = [
|
||||
"sessionKey": mainSessionKey,
|
||||
"message": message,
|
||||
@@ -1360,8 +1400,7 @@ final class TalkModeManager: NSObject {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
|
||||
}
|
||||
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||
return decoded.runId
|
||||
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
}
|
||||
|
||||
private func waitForChatCompletion(
|
||||
@@ -1440,7 +1479,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func waitForAssistantTextFromHistory(
|
||||
gateway: GatewayNodeSession,
|
||||
since: Double,
|
||||
since: Double?,
|
||||
timeoutSeconds: Int) async throws -> String?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
|
||||
@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: self.iconSystemName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
|
||||
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
|
||||
if let failureMessage = self.state.failureMessage {
|
||||
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
|
||||
.overlay {
|
||||
if self.style == .card || self.style == .sheet {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
|
||||
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task(id: self.pollTaskKey) {
|
||||
|
||||
@@ -50,6 +50,7 @@ Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewayHealthMonitor.swift
|
||||
Sources/Gateway/GatewayProblemView.swift
|
||||
Sources/Gateway/GatewayQuickSetupSheet.swift
|
||||
Sources/Gateway/NotificationPermissionGuidanceDialog.swift
|
||||
Sources/Gateway/GatewayServiceResolver.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/GatewayTrustPromptAlert.swift
|
||||
|
||||
@@ -14,10 +14,6 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
|
||||
self.authorization
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
self.addedRequests.append(request)
|
||||
}
|
||||
|
||||
@@ -200,25 +200,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
|
||||
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
var status: NotificationAuthorizationStatus = .notDetermined
|
||||
var requestAuthorizationResult = false
|
||||
var requestAuthorizationCalls = 0
|
||||
var addCalls = 0
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
self.requestAuthorizationCalls += 1
|
||||
if self.requestAuthorizationResult {
|
||||
self.status = .authorized
|
||||
} else {
|
||||
self.status = .denied
|
||||
}
|
||||
return self.requestAuthorizationResult
|
||||
func add(_: UNNotificationRequest) async throws {
|
||||
self.addCalls += 1
|
||||
}
|
||||
|
||||
func add(_: UNNotificationRequest) async throws {}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
|
||||
@@ -1230,13 +1221,65 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
hasStoredOperatorToken: false))
|
||||
}
|
||||
|
||||
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
|
||||
@Test @MainActor func operatorGatewayRequestedEventShowsNotificationGuidanceWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
|
||||
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
|
||||
|
||||
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.requestedKind,
|
||||
payload: AnyCodable(["id": "approval-notifications-off"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(center.requestAuthorizationCalls == 1)
|
||||
let prompt = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
|
||||
#expect(prompt.approvalId == "approval-notifications-off")
|
||||
}
|
||||
|
||||
@Test @MainActor func suppressedOperatorGatewayRequestedEventDoesNotShowNotificationGuidance() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .denied
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
|
||||
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
|
||||
appModel.dismissNotificationPermissionGuidancePrompt(suppressFuture: true)
|
||||
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.requestedKind,
|
||||
payload: AnyCodable(["id": "approval-suppressed"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorGatewayResolvedEventClearsNotificationGuidancePrompt() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .denied
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
appModel._test_resetExecApprovalNotificationGuidanceSuppression()
|
||||
defer { appModel._test_resetExecApprovalNotificationGuidanceSuppression() }
|
||||
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.requestedKind,
|
||||
payload: AnyCodable(["id": "approval-guidance-resolved"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
_ = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt())
|
||||
|
||||
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
|
||||
type: "event",
|
||||
event: ExecApprovalNotificationBridge.resolvedKind,
|
||||
payload: AnyCodable(["id": "approval-guidance-resolved"]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil)
|
||||
}
|
||||
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() throws {
|
||||
@@ -1298,6 +1341,78 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(res.error?.message.contains("CAMERA_DISABLED") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func systemNotifyReturnsUnavailableWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
|
||||
let paramsData = try JSONEncoder().encode(params)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "notify-off",
|
||||
command: OpenClawSystemCommand.notify.rawValue,
|
||||
paramsJSON: String(decoding: paramsData, as: UTF8.self))
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.code == .unavailable)
|
||||
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
|
||||
#expect(center.addCalls == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func systemNotifySchedulesWhenNotificationsAreAlreadyAllowed() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .authorized
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
let params = OpenClawSystemNotifyParams(title: "Approval", body: "Review request")
|
||||
let paramsData = try JSONEncoder().encode(params)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "notify-on",
|
||||
command: OpenClawSystemCommand.notify.rawValue,
|
||||
paramsJSON: String(decoding: paramsData, as: UTF8.self))
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
|
||||
#expect(res.ok)
|
||||
#expect(center.addCalls == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
|
||||
let paramsData = try JSONEncoder().encode(params)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "chat-push-off",
|
||||
command: OpenClawChatCommand.push.rawValue,
|
||||
paramsJSON: String(decoding: paramsData, as: UTF8.self))
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
|
||||
#expect(res.ok == false)
|
||||
#expect(res.error?.code == .unavailable)
|
||||
#expect(res.error?.message == "NOT_AUTHORIZED: notifications")
|
||||
#expect(center.addCalls == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor func chatPushSchedulesWhenNotificationsAreAlreadyAllowed() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .authorized
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
let params = OpenClawChatPushParams(text: "Build finished", speak: false)
|
||||
let paramsData = try JSONEncoder().encode(params)
|
||||
let req = BridgeInvokeRequest(
|
||||
id: "chat-push-on",
|
||||
command: OpenClawChatCommand.push.rawValue,
|
||||
paramsJSON: String(decoding: paramsData, as: UTF8.self))
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
|
||||
#expect(res.ok)
|
||||
#expect(center.addCalls == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async {
|
||||
let appModel = NodeAppModel()
|
||||
let params = OpenClawScreenRecordParams(format: "gif")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSourceGuardTests {
|
||||
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
|
||||
struct RootTabsSourceGuardTests {
|
||||
@Test func `hidden sidebar reveal uses destination header without reserved rail`() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
|
||||
@@ -38,7 +38,7 @@ import Testing
|
||||
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
|
||||
}
|
||||
|
||||
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
|
||||
@Test func `i pad split uses sliding sidebar while portrait keeps drawer overlay`() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
@@ -67,7 +67,7 @@ import Testing
|
||||
#expect(!drawerContent.contains("NavigationSplitView"))
|
||||
}
|
||||
|
||||
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
|
||||
@Test func `sidebar keeps navigation model destination only`() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let sidebarColumn = try Self.extract(
|
||||
@@ -114,7 +114,7 @@ import Testing
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
|
||||
}
|
||||
|
||||
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
|
||||
@Test func `sidebar routes use destination headers instead of repeated product branding`() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
@@ -148,7 +148,7 @@ import Testing
|
||||
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
|
||||
}
|
||||
|
||||
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
|
||||
@Test func `agents direct route keeps single sidebar control`() throws {
|
||||
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
@@ -165,7 +165,7 @@ import Testing
|
||||
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
}
|
||||
|
||||
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
|
||||
@Test func `routed headers use shared adaptive layout`() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
@@ -187,7 +187,7 @@ import Testing
|
||||
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
|
||||
@Test func `phone hub keeps docs as destination only`() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("case .docs:"))
|
||||
@@ -198,7 +198,7 @@ import Testing
|
||||
#expect(!source.contains("https://docs.openclaw.ai"))
|
||||
}
|
||||
|
||||
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
|
||||
@Test func `root shell preview matrix covers phone and I pad states`() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
|
||||
@@ -211,7 +211,7 @@ import Testing
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
|
||||
}
|
||||
|
||||
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
|
||||
@Test func `shared chat preview matrix covers connection states`() throws {
|
||||
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Chat connected\")"))
|
||||
@@ -226,7 +226,7 @@ import Testing
|
||||
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
|
||||
@Test func `phone hub keeps content above floating tab bar`() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
|
||||
@@ -235,7 +235,7 @@ import Testing
|
||||
#expect(!source.contains("bottomTabBarClearance"))
|
||||
}
|
||||
|
||||
@Test func phoneHubHeaderStaysTaskFirst() throws {
|
||||
@Test func `phone hub header stays task first`() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("private var gatewayActionRow: some View"))
|
||||
@@ -253,7 +253,7 @@ import Testing
|
||||
#expect(!source.contains("private func metric(label:"))
|
||||
}
|
||||
|
||||
@Test func workboardUsesRealGatewayMethods() throws {
|
||||
@Test func `workboard uses real gateway methods`() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("workboard.cards.list"))
|
||||
@@ -268,7 +268,7 @@ import Testing
|
||||
#expect(!source.contains("Multi-column queue control"))
|
||||
}
|
||||
|
||||
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
|
||||
@Test func `workboard create action surfaces unavailable reasons`() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
let createFunction = try Self.extract(
|
||||
source,
|
||||
@@ -295,7 +295,7 @@ import Testing
|
||||
#expect(createFunction.contains("return true"))
|
||||
}
|
||||
|
||||
@Test func taskScopeControlsSendRealGatewayParams() throws {
|
||||
@Test func `task scope controls send real gateway params`() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
|
||||
#expect(source.contains("private var boardScopeMenu: some View"))
|
||||
@@ -314,7 +314,7 @@ import Testing
|
||||
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
|
||||
@Test func `compact task rows keep phone native actions`() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let compactControls = try Self.extract(
|
||||
source,
|
||||
@@ -348,7 +348,7 @@ import Testing
|
||||
#expect(compactControls.contains("Label(\"Dispatch\""))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
|
||||
@Test func `skill workshop uses kanban lanes on wide I pad`() throws {
|
||||
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
|
||||
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
let content = try Self.extract(
|
||||
@@ -376,7 +376,7 @@ import Testing
|
||||
#expect(previewSource.contains("status: \"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
|
||||
@Test func `compact task rows have populated phone previews`() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
|
||||
@@ -387,7 +387,7 @@ import Testing
|
||||
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
|
||||
}
|
||||
|
||||
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
|
||||
@Test func `task screen preview matrices cover primary states`() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard states\")"))
|
||||
@@ -412,7 +412,7 @@ import Testing
|
||||
#expect(source.contains("\"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func activityPreviewMatrixCoversConnectionStates() throws {
|
||||
@Test func `activity preview matrix covers connection states`() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Activity states\")"))
|
||||
@@ -426,7 +426,7 @@ import Testing
|
||||
#expect(source.contains("title: \"Loading sessions\""))
|
||||
}
|
||||
|
||||
@Test func routedFeatureScreensReuseSharedProComponents() throws {
|
||||
@Test func `routed feature screens reuse shared pro components`() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
@@ -445,20 +445,22 @@ import Testing
|
||||
#expect(componentsSource.contains("struct ProStatusRow"))
|
||||
}
|
||||
|
||||
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
|
||||
@Test func `activity screen stays split from task feature screens`() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
|
||||
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(activitySource.contains("self.appModel.makeChatTransport()"))
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
|
||||
#expect(activitySource.contains("IPadSidebarScreenChrome("))
|
||||
#expect(!taskSource.contains("struct IPadActivityScreen"))
|
||||
#expect(!taskSource.contains("import OpenClawChatUI"))
|
||||
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
|
||||
@Test func `routed feature chrome stays split from task feature screens`() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
@@ -470,7 +472,7 @@ import Testing
|
||||
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
|
||||
@Test func `routed feature chrome keeps gateway pill actionable`() throws {
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let featureSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
@@ -486,14 +488,18 @@ import Testing
|
||||
.count == 1)
|
||||
}
|
||||
|
||||
@Test func routedGatewayPillsOpenGatewaySettings() throws {
|
||||
@Test func `routed gateway pills open gateway settings`() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsTabSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let notificationGuidanceSource = try String(
|
||||
contentsOf: Self.notificationPermissionGuidanceDialogSourceURL(),
|
||||
encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
@@ -512,15 +518,39 @@ import Testing
|
||||
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
|
||||
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(rootSource
|
||||
.matches(of: /SettingsProTab\(\s*headerLeadingAction: self\.sidebarHeaderLeadingAction,/)
|
||||
.count >= 1)
|
||||
#expect(rootSource
|
||||
.contains(
|
||||
"directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway"))
|
||||
#expect(rootSource.matches(of: /SettingsProTab\(\s*initialRoute: self\.selectedSettingsRoute,/).count == 1)
|
||||
#expect(rootSource.contains(".id(self.settingsTabViewID)"))
|
||||
#expect(rootSource.contains("@State private var selectedSettingsRouteRequestID: Int = 0"))
|
||||
#expect(rootSource.contains("self.selectedSettingsRouteRequestID &+= 1"))
|
||||
#expect(rootSource.contains("@State private var suppressedExecApprovalPromptIDForNotificationSettings"))
|
||||
#expect(rootSource.contains("private var activeExecApprovalPromptSuppressionID: String?"))
|
||||
#expect(rootSource.contains("suppressedApprovalID: self.activeExecApprovalPromptSuppressionID"))
|
||||
#expect(rootSource.contains("if destination.settingsRoute != .notifications"))
|
||||
#expect(rootSource.contains("if route != .notifications"))
|
||||
#expect(rootSource.contains("if route == nil"))
|
||||
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
|
||||
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
|
||||
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
|
||||
#expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange"))
|
||||
#expect(rootSource.contains("private func handleSettingsRouteChange(_ route: SettingsRoute?)"))
|
||||
#expect(settingsTabSource.contains("let onRouteChange: ((SettingsRoute?) -> Void)?"))
|
||||
#expect(settingsTabSource.contains("self.onRouteChange?(self.navigationPath.last)"))
|
||||
#expect(notificationGuidanceSource.contains("onSuppressFuture"))
|
||||
#expect(notificationGuidanceSource.contains("suppressFuture: true"))
|
||||
#expect(notificationGuidanceSource.contains("Text(\"Don't show again\")"))
|
||||
#expect(rootSource.contains("private func selectSettingsRoute(_ route: SettingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
@@ -566,7 +596,7 @@ import Testing
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
|
||||
@Test func `gateway settings preview matrix covers primary states`() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
|
||||
@@ -585,12 +615,15 @@ import Testing
|
||||
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
|
||||
}
|
||||
|
||||
@Test func nativeChatUsesGatewayTransport() throws {
|
||||
@Test func `native chat uses gateway transport`() throws {
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
let settingsSectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(channelsSource.contains("Message routing and external channel clients."))
|
||||
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
|
||||
#expect(settingsSectionsSource.contains("Message routing and external channel clients."))
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
@@ -603,6 +636,13 @@ import Testing
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func nodeAppModelSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
|
||||
}
|
||||
|
||||
private static func phoneHubSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
@@ -746,6 +786,13 @@ import Testing
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/NotificationPermissionGuidanceDialog.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabActionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
@@ -495,6 +495,9 @@ def produce_services_for_target(target)
|
||||
if target.fetch("capabilities").include?("APP_GROUPS")
|
||||
services[:app_group] = "on"
|
||||
end
|
||||
if target.fetch("capabilities").include?("APP_ATTEST")
|
||||
services[:app_attest] = "on"
|
||||
end
|
||||
services
|
||||
end
|
||||
|
||||
@@ -605,6 +608,15 @@ def validate_match_profile_capabilities!(target)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if capabilities.include?("APP_ATTEST")
|
||||
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
|
||||
unless app_attest_environments.include?("production")
|
||||
UI.user_error!(
|
||||
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_app_store_signing!(readonly:)
|
||||
|
||||
@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app also requires App Attest, and the main app and share extension both require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
@@ -120,17 +120,23 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
|
||||
Release:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: production
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
@@ -176,6 +182,8 @@ targets:
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
|
||||
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
@@ -680,83 +680,6 @@ struct GeneralSettings: View {
|
||||
case .missingNode, .missingGateway, .incompatible, .error: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var healthCard: some View {
|
||||
let snapshot = self.healthStore.snapshot
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.healthStore.state.tint)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"Link channel"
|
||||
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
let lastActivity = recent.updatedAt != nil
|
||||
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
|
||||
: "unknown"
|
||||
Text("Last activity: \(recent.key) \(lastActivity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let error = self.healthStore.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Health check pending…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.healthStore.refresh(onDemand: true) }
|
||||
} label: {
|
||||
if self.healthStore.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Run Health Check", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
.disabled(self.healthStore.isRefreshing)
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
Button {
|
||||
self.revealLogs()
|
||||
} label: {
|
||||
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RemoteStatus: Equatable {
|
||||
@@ -839,11 +762,6 @@ extension GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func healthAgeString(_ ms: Double?) -> String {
|
||||
guard let ms else { return "unknown" }
|
||||
return msToAge(ms)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct GeneralSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -221,16 +221,6 @@ final class InstancesStore {
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||
self.applyPresence(decoded)
|
||||
} catch {
|
||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) {
|
||||
do {
|
||||
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)
|
||||
|
||||
@@ -16,7 +16,6 @@ struct OpenClawApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@@ -34,6 +33,7 @@ struct OpenClawApp: App {
|
||||
|
||||
init() {
|
||||
OpenClawLogging.bootstrapIfNeeded()
|
||||
GatewayConnectivityCoordinator.shared.start()
|
||||
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user