mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 00:04:25 +08:00
Compare commits
2 Commits
fix/exec-p
...
temp/pr-95
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7514c823cb | ||
|
|
8eb9faacef |
@@ -4,14 +4,6 @@ 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"
|
||||
@@ -26,10 +18,6 @@ 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
|
||||
@@ -143,8 +131,7 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
|
||||
export OPENCLAW_GH_BIN
|
||||
need gh
|
||||
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 = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
const proc = spawnSync("gh", 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,7 +5,6 @@
|
||||
*/
|
||||
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";
|
||||
@@ -16,9 +15,8 @@ if (!runId) {
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync(resolvePlainGhBin(), args, {
|
||||
return execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
@@ -34,15 +32,14 @@ function githubRestJson(pathSuffix) {
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
||||
'token="$(gh 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: {
|
||||
...plainGhEnv(),
|
||||
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
||||
...process.env,
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
|
||||
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
@@ -1,76 +0,0 @@
|
||||
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.
|
||||
112
.github/workflows/ci.yml
vendored
112
.github/workflows/ci.yml
vendored
@@ -41,32 +41,11 @@ 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
|
||||
@@ -293,23 +272,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
|
||||
const nodeTestShards = runNodeFull
|
||||
? 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);
|
||||
@@ -387,7 +361,6 @@ 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
|
||||
@@ -853,7 +826,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -943,7 +916,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1024,7 +997,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1174,10 +1147,10 @@ jobs:
|
||||
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-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
max-parallel: 6
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1236,9 +1209,7 @@ 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"
|
||||
@@ -1252,55 +1223,28 @@ jobs:
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
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",
|
||||
},
|
||||
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`,
|
||||
);
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
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);
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1315,7 +1259,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-guards
|
||||
@@ -1457,7 +1401,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-additional-boundaries-a
|
||||
|
||||
@@ -244,11 +244,6 @@ 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
376
.github/workflows/qa-profile-evidence.yml
vendored
@@ -1,376 +0,0 @@
|
||||
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,9 +24,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
# Old PR events can carry a stale base SHA that predates current
|
||||
# trusted checker scripts. Use the workflow revision instead.
|
||||
ref: ${{ github.workflow_sha }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
id: app-token
|
||||
|
||||
42
.github/workflows/tui-pty.yml
vendored
Normal file
42
.github/workflows/tui-pty.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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,10 +297,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,16 +1,8 @@
|
||||
<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:permission="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:exported="false">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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,9 +204,6 @@ 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
|
||||
@@ -540,17 +537,6 @@ 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,7 +14,6 @@ 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
|
||||
@@ -74,9 +73,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -402,15 +399,6 @@ 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)
|
||||
@@ -460,7 +448,6 @@ class NodeRuntime(
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshExecApprovalsFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -490,11 +477,6 @@ 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()
|
||||
@@ -650,11 +632,7 @@ class NodeRuntime(
|
||||
put("idempotencyKey", JsonPrimitive(idempotencyKey))
|
||||
}
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
val ack = parseChatSendAck(json, response)
|
||||
ack.copy(runId = ack.runId ?: idempotencyKey)
|
||||
},
|
||||
refreshAfterTerminalSuccess = {
|
||||
chat.refresh()
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Voice-tab replies should speak through the dedicated reply speaker.
|
||||
@@ -842,24 +820,6 @@ 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()
|
||||
@@ -1035,9 +995,6 @@ class NodeRuntime(
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
} else {
|
||||
stopManualVoiceSession()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
|
||||
@@ -1867,47 +1824,11 @@ 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() }
|
||||
@@ -1922,6 +1843,15 @@ 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 =
|
||||
@@ -2154,196 +2084,6 @@ 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) {
|
||||
@@ -2458,19 +2198,12 @@ 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 = sanitizedLine.trim().ifEmpty { "Empty log entry" },
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
|
||||
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())
|
||||
@@ -2488,7 +2221,7 @@ class NodeRuntime(
|
||||
?: root["message"].asStringOrNull()
|
||||
?: line
|
||||
val normalizedMessage =
|
||||
sanitizeGatewayLogText(message)
|
||||
message
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(240)
|
||||
@@ -2496,9 +2229,8 @@ class NodeRuntime(
|
||||
return GatewayLogEntry(
|
||||
time = time,
|
||||
level = level,
|
||||
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
|
||||
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
|
||||
message = normalizedMessage,
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2587,7 +2319,6 @@ 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",
|
||||
@@ -3038,6 +2769,11 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
@@ -3110,7 +2846,6 @@ data class GatewaySkillsSummary(
|
||||
)
|
||||
|
||||
data class GatewaySkillSummary(
|
||||
val skillKey: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val source: String,
|
||||
@@ -3308,19 +3043,8 @@ 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,6 +393,12 @@ 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,6 +6,14 @@ 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,7 +1,6 @@
|
||||
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
|
||||
@@ -20,21 +19,11 @@ import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class ChatController internal constructor(
|
||||
class ChatController(
|
||||
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()
|
||||
@@ -278,9 +267,8 @@ class ChatController internal constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = requestGateway("chat.send", params.toString())
|
||||
val ack = parseChatSendAck(json, res)
|
||||
val actualRunId = ack.runId ?: runId
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
@@ -291,24 +279,7 @@ class ChatController internal constructor(
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
@@ -332,7 +303,7 @@ class ChatController internal constructor(
|
||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||
put("runId", JsonPrimitive(runId))
|
||||
}
|
||||
requestGateway("chat.abort", params.toString())
|
||||
session.request("chat.abort", params.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -385,7 +356,7 @@ class ChatController internal constructor(
|
||||
) {
|
||||
try {
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
session.request(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
|
||||
)
|
||||
@@ -420,7 +391,7 @@ class ChatController internal constructor(
|
||||
put("includeUnknown", JsonPrimitive(false))
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = requestGateway("sessions.list", params.toString())
|
||||
val res = session.request("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
@@ -437,7 +408,7 @@ class ChatController internal constructor(
|
||||
if (!force && last != null && now - last < 10_000) return
|
||||
lastHealthPollAtMs = now
|
||||
try {
|
||||
requestGateway("health", null)
|
||||
session.request("health", null)
|
||||
_healthOk.value = true
|
||||
} catch (_: Throwable) {
|
||||
_healthOk.value = false
|
||||
@@ -480,7 +451,7 @@ class ChatController internal constructor(
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
session.request(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
@@ -538,7 +509,8 @@ class ChatController internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -660,45 +632,6 @@ class ChatController internal constructor(
|
||||
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,
|
||||
@@ -746,16 +679,9 @@ class ChatController internal constructor(
|
||||
): 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,
|
||||
@@ -802,6 +728,17 @@ class ChatController internal constructor(
|
||||
_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"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,5 +1,6 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
@@ -11,7 +12,6 @@ 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()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -166,6 +166,14 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -189,7 +197,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
|
||||
@@ -260,6 +260,24 @@ 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,
|
||||
@@ -279,6 +297,28 @@ 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,6 +97,8 @@ class CanvasController {
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
@@ -203,6 +205,24 @@ 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,7 +4,6 @@ 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
|
||||
@@ -64,7 +63,7 @@ private class AndroidDeviceAppSource(
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
visibleInstalledApplications(packageManager)
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
@@ -91,13 +90,6 @@ 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,3 +109,6 @@ 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,7 +5,6 @@ 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
|
||||
@@ -95,11 +94,7 @@ internal fun CommandPalette(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close search",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
CommandIconButton(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")
|
||||
}
|
||||
@@ -267,6 +262,19 @@ 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,7 +5,8 @@ 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.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -91,19 +92,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
@@ -114,6 +115,23 @@ 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,6 +206,9 @@ 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,10 +7,7 @@ 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
|
||||
@@ -18,18 +15,13 @@ 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
|
||||
@@ -51,7 +43,6 @@ 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) {
|
||||
@@ -61,11 +52,6 @@ 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.",
|
||||
@@ -107,46 +93,7 @@ internal fun HealthLogsSettingsScreen(
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,26 +113,41 @@ private fun HealthStatusPanel(
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
HealthStatusRow(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) {
|
||||
@@ -208,7 +170,7 @@ private fun GatewayLogsPanel(
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
|
||||
GatewayLogRow(entry = entry)
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
@@ -223,16 +185,9 @@ private fun GatewayLogsPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(
|
||||
entry: GatewayLogEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClickLabel = "Open log entry", onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
@@ -244,11 +199,6 @@ private fun GatewayLogRow(
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1378,12 +1378,7 @@ private fun rememberPermissionState(
|
||||
photosGranted = permissions[photosPermission] ?: photosGranted
|
||||
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
|
||||
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
|
||||
notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
} else {
|
||||
true
|
||||
}
|
||||
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
|
||||
smsGranted =
|
||||
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&
|
||||
|
||||
@@ -9,10 +9,14 @@ 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.
|
||||
*/
|
||||
@@ -30,6 +34,7 @@ fun OpenClawTheme(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -50,3 +55,21 @@ 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,7 +2,6 @@ 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
|
||||
@@ -56,7 +55,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 current chat sessions. */
|
||||
/** Session browser for recent and currently-live chat sessions. */
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -74,7 +73,7 @@ internal fun SessionsScreen(
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
@@ -93,12 +92,12 @@ internal fun SessionsScreen(
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
@@ -107,16 +106,16 @@ internal fun SessionsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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 })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
showDot: Boolean = false,
|
||||
live: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -199,7 +198,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 (showDot) {
|
||||
if (live) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
@@ -259,7 +258,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) "Current" else "OpenClaw")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +273,19 @@ 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,
|
||||
@@ -308,21 +320,21 @@ private fun SessionMiniTag(text: String) {
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Current,
|
||||
Live,
|
||||
}
|
||||
|
||||
/** Empty-state title selected by the active session browser filter. */
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Current -> "No current session"
|
||||
SessionFilter.Live -> "No live 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.Current -> "Open Chat to start or resume the current session."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
/** Formats session timestamps for compact mobile metadata. */
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -15,7 +14,6 @@ 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
|
||||
@@ -92,6 +90,7 @@ 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
|
||||
@@ -107,7 +106,6 @@ internal enum class SettingsRoute {
|
||||
Profile,
|
||||
Voice,
|
||||
Agents,
|
||||
ProvidersModels,
|
||||
Approvals,
|
||||
CronJobs,
|
||||
Usage,
|
||||
@@ -138,7 +136,6 @@ 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)
|
||||
@@ -302,62 +299,29 @@ 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 issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshExecApprovals()
|
||||
}
|
||||
}
|
||||
val waitingCount = pendingToolCalls.count { it.isError != true }
|
||||
val issueCount = pendingToolCalls.count { it.isError == true }
|
||||
|
||||
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
|
||||
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
|
||||
SettingsMetric("Pending", waitingCount.toString()),
|
||||
SettingsMetric("Issues", issueCount.toString()),
|
||||
SettingsMetric("Active Runs", pendingRunCount.toString()),
|
||||
),
|
||||
)
|
||||
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) {
|
||||
if (pendingToolCalls.isEmpty()) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
ApprovalsPanel(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,7 +820,6 @@ 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(
|
||||
@@ -877,17 +840,7 @@ 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)
|
||||
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,
|
||||
)
|
||||
}
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
@@ -1108,11 +1061,7 @@ 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)) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
)
|
||||
SettingsBackButton(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)
|
||||
}
|
||||
@@ -1149,70 +1098,7 @@ internal data class SettingsMetric(
|
||||
)
|
||||
|
||||
@Composable
|
||||
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>) {
|
||||
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawListPanel(items = toolCalls) { toolCall ->
|
||||
ApprovalListRow(toolCall = toolCall)
|
||||
}
|
||||
@@ -1345,30 +1231,6 @@ 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}"
|
||||
|
||||
@@ -1532,6 +1394,15 @@ 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,6 +1253,16 @@ 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,24 +10,17 @@ 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
|
||||
|
||||
@@ -44,7 +37,6 @@ 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) {
|
||||
@@ -52,17 +44,6 @@ 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.",
|
||||
@@ -102,117 +83,25 @@ 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, onSkillClick = { selectedSkillKey = it.skillKey })
|
||||
else -> SkillsPanel(skills = skills)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 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,
|
||||
) {
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
|
||||
SkillListRow(skill = skill)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(
|
||||
skill: GatewaySkillSummary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
|
||||
leading = { ClawTextBadge(text = skillBadge(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,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,15 +135,6 @@ 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,10 +1,8 @@
|
||||
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
|
||||
@@ -70,7 +68,6 @@ 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
|
||||
@@ -180,8 +177,8 @@ fun VoiceScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
@@ -270,12 +267,12 @@ private fun DictationScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
VoicePlainIconButton(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)
|
||||
}
|
||||
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -407,7 +404,7 @@ private fun TalkSessionScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
VoicePlainIconButton(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)) {
|
||||
@@ -426,7 +423,7 @@ private fun TalkSessionScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -550,19 +547,14 @@ 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 = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -570,7 +562,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 = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
@@ -579,7 +571,7 @@ private fun VoiceHeader(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ClawPlainIconButton(
|
||||
VoicePlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
@@ -588,6 +580,34 @@ 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,
|
||||
@@ -841,10 +861,8 @@ private fun VoiceOrb(
|
||||
Surface(
|
||||
modifier = Modifier.size(112.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 7.dp,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -857,7 +875,7 @@ private fun VoiceOrb(
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.White,
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
@@ -874,7 +892,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) Color.White else Color.White.copy(alpha = 0.52f)),
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -40,7 +39,6 @@ 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
|
||||
@@ -65,7 +63,6 @@ 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
|
||||
@@ -156,11 +153,12 @@ fun ChatScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onMore = {
|
||||
@@ -263,11 +261,11 @@ private fun ChatSessionSwitcher(
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
@@ -290,11 +288,11 @@ private fun ChatSessionChip(
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
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)),
|
||||
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),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -309,56 +307,48 @@ private fun ChatSessionChip(
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "Ready"
|
||||
else -> "Offline"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Success
|
||||
healthOk -> ClawStatus.Neutral
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,13 +365,7 @@ private fun ModelPill(
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color =
|
||||
when (status) {
|
||||
ClawStatus.Success -> ClawTheme.colors.successSoft
|
||||
ClawStatus.Warning -> ClawTheme.colors.warningSoft
|
||||
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
|
||||
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
|
||||
},
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
@@ -593,15 +577,13 @@ private fun ChatBubble(
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
@@ -782,7 +764,7 @@ private fun ChatContextMeter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
@@ -954,7 +936,7 @@ internal fun resolveChatContextUsage(
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
@@ -991,6 +973,24 @@ 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,53 +185,6 @@ 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,17 +95,15 @@ internal fun ClawBottomNav(
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
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 = 6.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -133,13 +131,13 @@ private fun ClawBottomNavItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.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,11 +27,31 @@ internal fun ClawPanel(
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = null,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
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),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -189,6 +190,12 @@ 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,6 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -44,7 +43,7 @@ data class VoiceConversationEntry(
|
||||
)
|
||||
|
||||
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
|
||||
internal class MicCaptureManager(
|
||||
class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val createTranscriptionSession: suspend () -> String,
|
||||
@@ -55,12 +54,11 @@ internal class MicCaptureManager(
|
||||
) -> Unit,
|
||||
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
|
||||
/**
|
||||
* Send [message] to the gateway and return the full chat.send ACK.
|
||||
* Send [message] to the gateway and return the run ID.
|
||||
* [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) -> ChatSendAck,
|
||||
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
|
||||
private val speakAssistantReply: suspend (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
@@ -485,30 +483,24 @@ internal class MicCaptureManager(
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val ack =
|
||||
val runId =
|
||||
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
|
||||
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)
|
||||
}
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
removeFirstQueuedMessage()
|
||||
publishQueue()
|
||||
_isSending.value = false
|
||||
pendingAssistantEntryId = null
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
armPendingRunTimeout(runId)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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
|
||||
@@ -111,6 +108,7 @@ 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
|
||||
@@ -383,20 +381,11 @@ class TalkModeManager internal constructor(
|
||||
reloadConfig()
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
val prompt = buildPrompt(command)
|
||||
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 runId = sendChat(prompt, session)
|
||||
val ok = waitForChatFinal(runId)
|
||||
val assistant =
|
||||
consumeRunText(runId)
|
||||
?: waitForAssistantText(
|
||||
session,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (!assistant.isNullOrBlank()) {
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
@@ -409,9 +398,8 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "speakWakeCommand failed: ${err.message}")
|
||||
} finally {
|
||||
onComplete()
|
||||
}
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,26 +1604,16 @@ 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 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)
|
||||
val runId = sendChat(prompt, session)
|
||||
Log.d(tag, "chat.send ok runId=$runId")
|
||||
val ok = 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,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (assistant.isNullOrBlank()) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "assistant text timeout runId=$runId")
|
||||
@@ -1701,7 +1679,7 @@ class TalkModeManager internal constructor(
|
||||
private suspend fun sendChat(
|
||||
message: String,
|
||||
session: GatewaySession,
|
||||
): ChatSendAck {
|
||||
): String {
|
||||
val runId = UUID.randomUUID().toString()
|
||||
armPendingRun(runId)
|
||||
val params =
|
||||
@@ -1714,15 +1692,11 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
try {
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseChatSendAck(json, res)
|
||||
val actualRunId = parsed.runId ?: runId
|
||||
if (actualRunId != runId) {
|
||||
pendingRunId = actualRunId
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
}
|
||||
if (parsed.isTerminal) {
|
||||
clearPendingRun(actualRunId)
|
||||
}
|
||||
return parsed.copy(runId = actualRunId)
|
||||
return parsed
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
throw err
|
||||
@@ -1803,7 +1777,7 @@ class TalkModeManager internal constructor(
|
||||
|
||||
private suspend fun waitForAssistantText(
|
||||
session: GatewaySession,
|
||||
sinceSeconds: Double?,
|
||||
sinceSeconds: Double,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
<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(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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,18 +204,17 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
|
||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
val qrJson =
|
||||
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
fun resolveScannedSetupCodeRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
|
||||
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
|
||||
val qrJson = """{"setupCode":"invalid"}"""
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
|
||||
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
|
||||
val qrJson = """{"setupCode":{"nested":"value"}}"""
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
|
||||
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
|
||||
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
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
|
||||
@@ -107,7 +105,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.ProvidersModels, providersRow.settingsRoute)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,206 +157,10 @@ 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,6 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -35,7 +34,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -85,7 +84,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
"run-1"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -112,7 +111,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-voice-e2e")
|
||||
ChatSendAck(runId = "run-voice-e2e", status = "started")
|
||||
"run-voice-e2e"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,88 +134,6 @@ 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()
|
||||
@@ -313,11 +230,10 @@ class MicCaptureManagerTest {
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
|
||||
createTranscriptionSession: suspend () -> String = { "transcription-1" },
|
||||
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
"run-1"
|
||||
},
|
||||
refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
): MicCaptureManager =
|
||||
MicCaptureManager(
|
||||
context =
|
||||
@@ -329,7 +245,6 @@ 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 run-as "$PACKAGE_NAME" am broadcast --user 0 \
|
||||
adb shell am broadcast \
|
||||
-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 run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
fi
|
||||
|
||||
echo "$ARTIFACT_DIR"
|
||||
|
||||
@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- ShareExtension
|
||||
- ActivityWidget
|
||||
- WatchApp
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI
|
||||
|
||||
excluded:
|
||||
- ../macos
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
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,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
|
||||
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(OpenClawActivityStyle.info)
|
||||
.foregroundStyle(.cyan)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(OpenClawActivityStyle.danger)
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(OpenClawActivityStyle.ok)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(OpenClawActivityStyle.warn)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return OpenClawActivityStyle.danger }
|
||||
if state.isConnecting { return OpenClawActivityStyle.info }
|
||||
if state.isIdle { return OpenClawActivityStyle.ok }
|
||||
return OpenClawActivityStyle.warn
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .cyan }
|
||||
if state.isIdle { return .green }
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
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", "APP_ATTEST"],
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ 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`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
@@ -102,7 +102,6 @@ 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.
|
||||
|
||||
@@ -158,7 +157,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 must also have App Attest enabled.
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -244,7 +243,6 @@ 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.
|
||||
@@ -261,7 +259,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.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Relay mode requires a reachable relay base URL and uses App Attest plus 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,7 +6,6 @@
|
||||
|
||||
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 ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? Color.accentColor : 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: OpenClawBrand.info
|
||||
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
|
||||
@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
|
||||
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,11 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let uiAccent = UIColor { traits in
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: 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)
|
||||
@@ -66,7 +64,6 @@ 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)
|
||||
|
||||
@@ -819,11 +819,8 @@ extension SettingsProTab {
|
||||
|
||||
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 \
|
||||
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
|
||||
delivery data.
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: OpenClawBrand.info,
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -476,7 +476,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
icon: "cross.case",
|
||||
color: OpenClawBrand.info,
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
|
||||
|
||||
func settingsSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
|
||||
@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
|
||||
if let errorText = self.normalized(self.errorText) {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
if self.isResolving {
|
||||
|
||||
@@ -86,12 +86,8 @@
|
||||
<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>
|
||||
|
||||
@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.foregroundStyle(.orange)
|
||||
.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 ? OpenClawBrand.accent : Color.secondary)
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
|
||||
|
||||
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? Color.accentColor : 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(OpenClawBrand.ok)
|
||||
.foregroundStyle(.green)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connected")
|
||||
|
||||
@@ -632,7 +632,6 @@ struct OpenClawApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootTabs()
|
||||
.tint(OpenClawBrand.accent)
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
@@ -687,7 +686,6 @@ struct OpenClawApp: App {
|
||||
.flatMap(\.windows)
|
||||
.forEach { window in
|
||||
window.overrideUserInterfaceStyle = style
|
||||
window.tintColor = OpenClawBrand.uiAccent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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,29 +15,14 @@ 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 }
|
||||
@@ -47,8 +32,7 @@ struct PushBuildConfig {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == "https"
|
||||
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
|
||||
.contains(components.host?.lowercased() ?? "")
|
||||
&& components.host?.lowercased() == Self.openClawHostedRelayHost
|
||||
&& components.user == nil
|
||||
&& components.password == nil
|
||||
}
|
||||
@@ -66,14 +50,6 @@ 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")
|
||||
}
|
||||
|
||||
@@ -101,24 +77,9 @@ 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)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return T(rawValue: trimmed) ?? 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")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment,
|
||||
proofPolicy: self.buildConfig.proofPolicy)
|
||||
guard self.buildConfig.apnsEnvironment == .production else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushAPNsEnvironment=production")
|
||||
}
|
||||
guard let relayClient = self.relayClient else {
|
||||
throw PushRelayError.relayBaseURLMissing
|
||||
}
|
||||
@@ -96,9 +96,6 @@ 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)
|
||||
{
|
||||
@@ -115,16 +112,14 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
let response = try await relayClient.register(PushRelayRegistrationInput(
|
||||
let response = try await relayClient.register(
|
||||
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,
|
||||
@@ -134,10 +129,7 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
|
||||
lastAPNsTokenHashHex: tokenHashHex,
|
||||
installationId: installationId,
|
||||
lastTransport: self.buildConfig.transport.rawValue,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
relayProfile: self.buildConfig.relayProfile.rawValue,
|
||||
proofPolicy: self.buildConfig.proofPolicy.rawValue)
|
||||
lastTransport: self.buildConfig.transport.rawValue)
|
||||
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||
return try Self.encodePayload(
|
||||
RelayGatewayPushRegistrationPayload(
|
||||
@@ -159,30 +151,6 @@ 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,9 +40,6 @@ 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
|
||||
@@ -66,16 +63,12 @@ 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 simulatorProof: PushRelaySimulatorProofPayload?
|
||||
var appAttest: PushRelayAppAttestPayload
|
||||
var receipt: PushRelayReceiptPayload
|
||||
}
|
||||
|
||||
struct PushRelayRegisterResponse: Decodable {
|
||||
@@ -100,34 +93,23 @@ 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,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof {
|
||||
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
|
||||
let service = DCAppAttestService.shared
|
||||
guard service.isSupported else {
|
||||
throw PushRelayError.unsupportedAppAttest
|
||||
}
|
||||
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service)
|
||||
let attestationObject = try await self.attestKeyIfNeeded(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
challenge: challenge,
|
||||
scope: scope)
|
||||
challenge: challenge)
|
||||
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||
let assertion = try await self.generateAssertion(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
signedPayloadHash: signedPayloadHash,
|
||||
scope: scope)
|
||||
signedPayloadHash: signedPayloadHash)
|
||||
|
||||
return PushRelayAppAttestProof(
|
||||
keyId: keyID,
|
||||
@@ -137,27 +119,21 @@ private final class PushRelayAppAttestService {
|
||||
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||
}
|
||||
|
||||
private func loadOrCreateKeyID(
|
||||
using service: DCAppAttestService,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
|
||||
!existing.isEmpty
|
||||
{
|
||||
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let keyID = try await service.generateKey()
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
|
||||
return keyID
|
||||
}
|
||||
|
||||
private func attestKeyIfNeeded(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
challenge: String,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
challenge: String)
|
||||
async throws -> String? {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
|
||||
return nil
|
||||
}
|
||||
let challengeData = Data(challenge.utf8)
|
||||
@@ -166,21 +142,20 @@ 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, scope: scope)
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
|
||||
return attestation.base64EncodedString()
|
||||
}
|
||||
|
||||
private func generateAssertion(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
signedPayloadHash: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
signedPayloadHash: Data)
|
||||
async throws -> Data {
|
||||
do {
|
||||
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||
} catch {
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -215,47 +190,6 @@ 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
|
||||
@@ -264,7 +198,6 @@ 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
|
||||
@@ -275,57 +208,46 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
Self.normalizeBaseURLString(self.baseURL)
|
||||
}
|
||||
|
||||
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
|
||||
func register(
|
||||
installationId: String,
|
||||
bundleId: String,
|
||||
appVersion: String,
|
||||
environment: PushAPNsEnvironment,
|
||||
distribution: PushDistributionMode,
|
||||
apnsTokenHex: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity)
|
||||
async throws -> PushRelayRegisterResponse {
|
||||
let challenge = try await self.fetchChallenge()
|
||||
let signedPayload = PushRelayRegisterSignedPayload(
|
||||
challengeId: challenge.challengeId,
|
||||
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)
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
environment: environment.rawValue,
|
||||
distribution: distribution.rawValue,
|
||||
gateway: gatewayIdentity,
|
||||
appVersion: appVersion,
|
||||
apnsToken: apnsTokenHex)
|
||||
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||
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,
|
||||
let appAttest = try await self.appAttest.createProof(
|
||||
challenge: challenge.challenge,
|
||||
signedPayloadData: signedPayloadData,
|
||||
scope: appAttestScope)
|
||||
let receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
|
||||
let simulatorProof = try self.createSimulatorProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
signedPayloadData: signedPayloadData)
|
||||
signedPayload: signedPayloadData)
|
||||
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
|
||||
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: appAttest.map {
|
||||
PushRelayAppAttestPayload(
|
||||
keyId: $0.keyId,
|
||||
attestationObject: $0.attestationObject,
|
||||
assertion: $0.assertion,
|
||||
clientDataHash: $0.clientDataHash,
|
||||
signedPayloadBase64: $0.signedPayloadBase64)
|
||||
},
|
||||
receipt: receipt,
|
||||
simulatorProof: simulatorProof)
|
||||
appAttest: PushRelayAppAttestPayload(
|
||||
keyId: appAttest.keyId,
|
||||
attestationObject: appAttest.attestationObject,
|
||||
assertion: appAttest.assertion,
|
||||
clientDataHash: appAttest.clientDataHash,
|
||||
signedPayloadBase64: appAttest.signedPayloadBase64),
|
||||
receipt: PushRelayReceiptPayload(base64: receiptBase64))
|
||||
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/register")
|
||||
var request = URLRequest(url: endpoint)
|
||||
@@ -340,8 +262,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(scope: appAttestScope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
}
|
||||
throw PushRelayError.requestFailed(
|
||||
status: status,
|
||||
@@ -350,43 +272,6 @@ 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,4 +1,3 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
private struct StoredPushRelayRegistrationState: Codable {
|
||||
@@ -11,9 +10,6 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String?
|
||||
var relayProfile: String?
|
||||
var proofPolicy: String?
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
@@ -22,13 +18,6 @@ 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
|
||||
@@ -39,9 +28,6 @@ enum PushRelayRegistrationStore {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
static func loadRegistrationState() -> RegistrationState? {
|
||||
@@ -62,10 +48,7 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: decoded.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
|
||||
installationId: decoded.installationId,
|
||||
lastTransport: decoded.lastTransport,
|
||||
apnsEnvironment: decoded.apnsEnvironment ?? "production",
|
||||
relayProfile: decoded.relayProfile ?? "production",
|
||||
proofPolicy: decoded.proofPolicy ?? "appleStrict")
|
||||
lastTransport: decoded.lastTransport)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -79,10 +62,7 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: state.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
|
||||
installationId: state.installationId,
|
||||
lastTransport: state.lastTransport,
|
||||
apnsEnvironment: state.apnsEnvironment,
|
||||
relayProfile: state.relayProfile,
|
||||
proofPolicy: state.proofPolicy)
|
||||
lastTransport: state.lastTransport)
|
||||
guard let data = try? JSONEncoder().encode(stored),
|
||||
let raw = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
@@ -91,66 +71,37 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
static func clearAppAttestKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
|
||||
}
|
||||
|
||||
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
|
||||
static func loadAttestedKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
static func saveAttestedKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
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)"
|
||||
static func clearAttestedKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,61 +1008,40 @@ 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 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 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 shouldIncremental = self.shouldUseIncrementalTTS()
|
||||
var streamingTask: Task<Void, Never>?
|
||||
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
|
||||
if shouldIncremental {
|
||||
self.resetIncrementalSpeech()
|
||||
streamingTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.streamAssistant(runId: runId, gateway: gateway)
|
||||
}
|
||||
}
|
||||
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
|
||||
if assistantText == nil, shouldIncremental {
|
||||
@@ -1074,7 +1053,7 @@ final class TalkModeManager: NSObject {
|
||||
if assistantText == nil {
|
||||
assistantText = try await self.waitForAssistantTextFromHistory(
|
||||
gateway: gateway,
|
||||
since: Self.chatSendHistorySince(response: ack, startedAt: startedAt),
|
||||
since: startedAt,
|
||||
timeoutSeconds: completion.state == .final ? 12 : 25)
|
||||
}
|
||||
guard let assistantText else {
|
||||
@@ -1364,27 +1343,8 @@ final class TalkModeManager: NSObject {
|
||||
var assistantText: 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 {
|
||||
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
|
||||
struct SendResponse: Decodable { let runId: String }
|
||||
let payload: [String: Any] = [
|
||||
"sessionKey": mainSessionKey,
|
||||
"message": message,
|
||||
@@ -1400,7 +1360,8 @@ final class TalkModeManager: NSObject {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
|
||||
}
|
||||
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
|
||||
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
|
||||
return decoded.runId
|
||||
}
|
||||
|
||||
private func waitForChatCompletion(
|
||||
@@ -1479,7 +1440,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 ? OpenClawBrand.warn : OpenClawBrand.accent)
|
||||
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
|
||||
.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(OpenClawBrand.danger)
|
||||
.foregroundStyle(.red)
|
||||
.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(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
|
||||
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task(id: self.pollTaskKey) {
|
||||
|
||||
@@ -495,9 +495,6 @@ 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
|
||||
|
||||
@@ -608,15 +605,6 @@ 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 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`.
|
||||
`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`.
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
@@ -120,23 +120,17 @@ 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:
|
||||
@@ -182,8 +176,6 @@ 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,6 +680,83 @@ 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 {
|
||||
@@ -762,6 +839,11 @@ 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,6 +221,16 @@ 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,6 +16,7 @@ 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
|
||||
@@ -33,7 +34,6 @@ struct OpenClawApp: App {
|
||||
|
||||
init() {
|
||||
OpenClawLogging.bootstrapIfNeeded()
|
||||
GatewayConnectivityCoordinator.shared.start()
|
||||
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
|
||||
@@ -1045,6 +1045,16 @@ extension MenuSessionsInjector {
|
||||
return item
|
||||
}
|
||||
|
||||
private func formatVersionLabel(_ version: String) -> String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return version }
|
||||
if trimmed.hasPrefix("v") { return trimmed }
|
||||
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
|
||||
return "v\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
|
||||
@@ -217,6 +217,18 @@ extension String? {
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
fileprivate func dedupedPreserveOrder() -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for item in self where !seen.contains(item) {
|
||||
seen.insert(item)
|
||||
result.append(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionLoadError: LocalizedError {
|
||||
case gatewayUnavailable(String)
|
||||
case decodeFailed(String)
|
||||
|
||||
@@ -391,40 +391,20 @@ actor TalkModeRuntime {
|
||||
idempotencyKey: runId,
|
||||
attachments: [])
|
||||
guard self.isCurrent(gen) else { return }
|
||||
let normalizedStatus = Self.normalizedChatSendStatus(response.status)
|
||||
self.logger.info(
|
||||
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
|
||||
"status=\(normalizedStatus, privacy: .public) " +
|
||||
"session=\(sessionKey, privacy: .public)")
|
||||
if Self.isTerminalChatSendFailure(response.status) {
|
||||
self.logger.warning(
|
||||
"talk chat.send terminal ack runId=\(response.runId, privacy: .public) " +
|
||||
"status=\(normalizedStatus, privacy: .public)")
|
||||
await self.resumeListeningIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
var assistantText: String?
|
||||
if Self.isTerminalChatSendSuccess(response.status) {
|
||||
self.logger.info(
|
||||
"talk chat.send terminal ok runId=\(response.runId, privacy: .public); " +
|
||||
"using history fallback")
|
||||
var assistantText = await self.waitForAssistantEventText(
|
||||
sessionKey: sessionKey,
|
||||
runId: response.runId,
|
||||
timeoutSeconds: 45)
|
||||
if assistantText == nil {
|
||||
self.logger.warning("talk assistant event text missing; using history fallback")
|
||||
assistantText = await self.waitForAssistantTextFromHistory(
|
||||
sessionKey: sessionKey,
|
||||
since: nil,
|
||||
since: startedAt,
|
||||
timeoutSeconds: 12)
|
||||
} else {
|
||||
assistantText = await self.waitForAssistantEventText(
|
||||
sessionKey: sessionKey,
|
||||
runId: response.runId,
|
||||
timeoutSeconds: 45)
|
||||
if assistantText == nil {
|
||||
self.logger.warning("talk assistant event text missing; using history fallback")
|
||||
assistantText = await self.waitForAssistantTextFromHistory(
|
||||
sessionKey: sessionKey,
|
||||
since: startedAt,
|
||||
timeoutSeconds: 12)
|
||||
}
|
||||
}
|
||||
guard let assistantText
|
||||
else {
|
||||
@@ -519,19 +499,6 @@ actor TalkModeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
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 matchesSessionKey(_ incoming: String, _ current: String) -> Bool {
|
||||
let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
@@ -542,7 +509,7 @@ actor TalkModeRuntime {
|
||||
|
||||
private func waitForAssistantTextFromHistory(
|
||||
sessionKey: String,
|
||||
since: Double?,
|
||||
since: Double,
|
||||
timeoutSeconds: Int) async -> String?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
|
||||
@@ -23,6 +23,7 @@ struct VoiceWakeSettings: View {
|
||||
@State private var meterStartupTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
@State private var triggerEntries: [TriggerEntry] = []
|
||||
private let fieldLabelWidth: CGFloat = 140
|
||||
private let controlWidth: CGFloat = 240
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
|
||||
|
||||
@@ -368,6 +368,31 @@ final class VoiceWakeTester {
|
||||
}
|
||||
}
|
||||
|
||||
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let detectedAt = Date()
|
||||
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
|
||||
|
||||
while !self.isStopping {
|
||||
let now = Date()
|
||||
if now >= hardStop { break }
|
||||
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
if !self.isStopping {
|
||||
self.stop()
|
||||
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||
if let detectedText {
|
||||
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
|
||||
Task { @MainActor in onUpdate(.detected(detectedText)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSilenceCheck(
|
||||
triggers: [String],
|
||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
||||
|
||||
@@ -283,7 +283,7 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(OpenClawChatTheme.accent.opacity(0.08))
|
||||
.background(Color.accentColor.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -550,7 +550,7 @@ struct OpenClawChatComposer: View {
|
||||
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
|
||||
.fill(OpenClawChatTheme.danger))
|
||||
.fill(Color.red))
|
||||
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
|
||||
.accessibilityLabel("Stop response")
|
||||
.disabled(self.viewModel.isAborting)
|
||||
|
||||
@@ -57,7 +57,7 @@ private struct ChatMarkdownStyle: ViewModifier {
|
||||
}
|
||||
|
||||
private var inlineStyle: InlineStyle {
|
||||
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
|
||||
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||
return InlineStyle()
|
||||
.code(.monospaced, .fontScale(codeScale))
|
||||
|
||||
@@ -25,7 +25,7 @@ struct ChatAgentAvatar: View {
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
|
||||
(self.tint ?? Color.accentColor).opacity(0.95),
|
||||
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
@@ -33,7 +33,7 @@ struct ChatAgentAvatar: View {
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
|
||||
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
|
||||
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
|
||||
}
|
||||
|
||||
|
||||
@@ -152,18 +152,6 @@ enum OpenClawChatTheme {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var accent: Color {
|
||||
self.userBubble
|
||||
}
|
||||
|
||||
static var danger: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .systemRed)
|
||||
#else
|
||||
Color(uiColor: .systemRed)
|
||||
#endif
|
||||
}
|
||||
|
||||
static var assistantBubble: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: self.assistantBubbleDynamicNSColor)
|
||||
|
||||
@@ -235,7 +235,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
showsSessionSwitcher: false,
|
||||
style: .onboarding,
|
||||
markdownVariant: .standard,
|
||||
userAccent: OpenClawChatTheme.accent)
|
||||
userAccent: .blue)
|
||||
}
|
||||
|
||||
private struct OpenClawChatPreview: View {
|
||||
@@ -250,7 +250,7 @@ private struct OpenClawChatPreview: View {
|
||||
showsSessionSwitcher: true,
|
||||
style: .standard,
|
||||
markdownVariant: .standard,
|
||||
userAccent: OpenClawChatTheme.accent,
|
||||
userAccent: .blue,
|
||||
showsAssistantTrace: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ public struct OpenClawChatView: View {
|
||||
systemImage: "bubble.left.and.bubble.right.fill",
|
||||
title: self.emptyStateTitle,
|
||||
message: self.emptyStateMessage,
|
||||
tint: OpenClawChatTheme.accent,
|
||||
tint: .accentColor,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
@@ -868,32 +868,25 @@ public final class OpenClawChatViewModel {
|
||||
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
|
||||
self.armPendingRunTimeout(runId: response.runId)
|
||||
}
|
||||
if response.status == "ok" {
|
||||
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
||||
await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
self.finishPendingRunAfterTerminalOkSendAck(response)
|
||||
} else if !self.finishPendingRunIfTerminalSendAck(response) {
|
||||
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
||||
await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
if !self.clearPendingRunIfAssistantMessagePresent(
|
||||
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
|
||||
await self.refreshHistoryAfterRun(historyRequest: historyContext)
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
if !self.clearPendingRunIfAssistantMessagePresent(
|
||||
runId: response.runId,
|
||||
after: userMessageTimestamp)
|
||||
{
|
||||
self.armPostSendRefreshFallback(
|
||||
runId: response.runId,
|
||||
after: userMessageTimestamp)
|
||||
{
|
||||
self.armPostSendRefreshFallback(
|
||||
runId: response.runId,
|
||||
sessionSnapshot: sessionSnapshot,
|
||||
userMessageTimestamp: userMessageTimestamp)
|
||||
self.armRunCompletionRefresh(
|
||||
runId: response.runId,
|
||||
sessionSnapshot: sessionSnapshot,
|
||||
userMessageTimestamp: userMessageTimestamp)
|
||||
}
|
||||
sessionSnapshot: sessionSnapshot,
|
||||
userMessageTimestamp: userMessageTimestamp)
|
||||
self.armRunCompletionRefresh(
|
||||
runId: response.runId,
|
||||
sessionSnapshot: sessionSnapshot,
|
||||
userMessageTimestamp: userMessageTimestamp)
|
||||
}
|
||||
} catch {
|
||||
guard self.isCurrentSession(sessionSnapshot) else { return }
|
||||
self.removePendingLocalUserEcho(for: runId)
|
||||
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
|
||||
self.clearPendingRun(runId)
|
||||
self.errorText = error.localizedDescription
|
||||
self.logDiagnostic(
|
||||
@@ -1724,48 +1717,6 @@ public final class OpenClawChatViewModel {
|
||||
return "Chat failed"
|
||||
}
|
||||
|
||||
private func finishPendingRunAfterTerminalOkSendAck(_ response: OpenClawChatSendResponse) {
|
||||
self.clearPendingRun(response.runId)
|
||||
self.pendingToolCallsById = [:]
|
||||
self.streamingAssistantText = nil
|
||||
self.logDiagnostic(
|
||||
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
||||
+ "runId=\(response.runId) status=ok")
|
||||
}
|
||||
|
||||
private func finishPendingRunIfTerminalSendAck(_ response: OpenClawChatSendResponse) -> Bool {
|
||||
switch response.status {
|
||||
case "timeout":
|
||||
self.removePendingLocalUserEcho(for: response.runId)
|
||||
self.clearPendingRun(response.runId)
|
||||
self.pendingToolCallsById = [:]
|
||||
self.streamingAssistantText = nil
|
||||
self.errorText = "Chat failed before the run started; try again."
|
||||
self.logDiagnostic(
|
||||
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
||||
+ "runId=\(response.runId) status=timeout")
|
||||
return true
|
||||
case "error":
|
||||
self.removePendingLocalUserEcho(for: response.runId)
|
||||
self.clearPendingRun(response.runId)
|
||||
self.pendingToolCallsById = [:]
|
||||
self.streamingAssistantText = nil
|
||||
self.errorText = "Chat failed before the run started; try again."
|
||||
self.logDiagnostic(
|
||||
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
|
||||
+ "runId=\(response.runId) status=error")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func removePendingLocalUserEcho(for runId: String) {
|
||||
guard let messageID = self.pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
|
||||
self.messages.removeAll { $0.id == messageID }
|
||||
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
|
||||
}
|
||||
|
||||
private func armPostSendRefreshFallback(
|
||||
runId: String,
|
||||
sessionSnapshot: SessionSnapshot,
|
||||
|
||||
@@ -21,15 +21,6 @@ private func chatErrorMessage(role: String, errorMessage: String, timestamp: Dou
|
||||
])
|
||||
}
|
||||
|
||||
fileprivate extension Array where Element == OpenClawChatMessage {
|
||||
func containsUserText(_ text: String) -> Bool {
|
||||
self.contains { message in
|
||||
message.role == "user" &&
|
||||
message.content.contains { $0.text == text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func historyPayload(
|
||||
sessionKey: String = "main",
|
||||
sessionId: String? = "sess-main",
|
||||
@@ -113,7 +104,6 @@ private func makeViewModel(
|
||||
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
|
||||
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
|
||||
healthResponses: [Bool] = [true],
|
||||
initialThinkingLevel: String? = nil,
|
||||
@@ -132,7 +122,6 @@ private func makeViewModel(
|
||||
compactSessionHook: compactSessionHook,
|
||||
setSessionModelHook: setSessionModelHook,
|
||||
setSessionThinkingHook: setSessionThinkingHook,
|
||||
sendMessageHook: sendMessageHook,
|
||||
waitForRunCompletionHook: waitForRunCompletionHook,
|
||||
healthResponses: healthResponses)
|
||||
let vm = await MainActor.run {
|
||||
@@ -358,7 +347,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
|
||||
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
|
||||
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
|
||||
private let sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)?
|
||||
private let waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)?
|
||||
private let healthResponses: [Bool]
|
||||
|
||||
@@ -376,7 +364,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
|
||||
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
|
||||
healthResponses: [Bool] = [true])
|
||||
{
|
||||
@@ -390,7 +377,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
self.compactSessionHook = compactSessionHook
|
||||
self.setSessionModelHook = setSessionModelHook
|
||||
self.setSessionThinkingHook = setSessionThinkingHook
|
||||
self.sendMessageHook = sendMessageHook
|
||||
self.waitForRunCompletionHook = waitForRunCompletionHook
|
||||
self.healthResponses = healthResponses
|
||||
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
||||
@@ -449,9 +435,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
await self.state.sentSessionKeysAppend(sessionKey)
|
||||
await self.state.sentRunIdsAppend(idempotencyKey)
|
||||
await self.state.sentThinkingLevelsAppend(thinking)
|
||||
if let sendMessageHook {
|
||||
return try await sendMessageHook(idempotencyKey)
|
||||
}
|
||||
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
@@ -897,50 +880,6 @@ struct ChatViewModelTests {
|
||||
#expect(await MainActor.run { vm.input } == "second")
|
||||
}
|
||||
|
||||
@Test func terminalOkSendAckClearsPendingRunWithoutWaitingForCompletion() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = historyPayload(sessionId: sessionId, messages: [])
|
||||
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
|
||||
await sendUserMessage(vm, text: "cached")
|
||||
try await waitUntil("terminal ok ack clears pending run") {
|
||||
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
|
||||
}
|
||||
|
||||
#expect(await MainActor.run { vm.errorText } == nil)
|
||||
#expect(await transport.waitCompletionRunIds().isEmpty)
|
||||
#expect(await MainActor.run { vm.messages.containsUserText("cached") })
|
||||
}
|
||||
|
||||
@Test func terminalTimeoutSendAckSurfacesErrorAndAllowsNextSend() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = historyPayload(sessionId: sessionId, messages: [])
|
||||
let sendCount = AsyncCounter()
|
||||
let (transport, vm) = await makeViewModel(
|
||||
historyResponses: [history],
|
||||
sendMessageHook: { runId in
|
||||
let count = await sendCount.increment()
|
||||
return OpenClawChatSendResponse(
|
||||
runId: runId,
|
||||
status: count == 1 ? "timeout" : "ok")
|
||||
})
|
||||
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
|
||||
|
||||
await sendUserMessage(vm, text: "first")
|
||||
try await waitUntil("timeout ack clears pending run") {
|
||||
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
|
||||
}
|
||||
#expect(await transport.sentRunIds().count == 1)
|
||||
#expect(await MainActor.run { vm.errorText } == "Chat failed before the run started; try again.")
|
||||
#expect(await MainActor.run { !vm.messages.containsUserText("first") })
|
||||
|
||||
await sendUserMessage(vm, text: "second")
|
||||
try await waitUntil("second send is accepted after timeout ack") {
|
||||
await transport.sentRunIds().count == 2
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
|
||||
@@ -138,7 +138,6 @@ const config = {
|
||||
entry: rootEntries,
|
||||
ignoreDependencies: [
|
||||
"@openclaw/*",
|
||||
"cross-spawn",
|
||||
"file-type",
|
||||
"playwright-core",
|
||||
"sqlite-vec",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
|
||||
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
|
||||
3ac3be8b7e201eb577854806a9806ba90acbfb2616e14b3ffd1169f188620303 config-baseline.json
|
||||
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
|
||||
769899651e2769833ae7e9c8fbf402e55f3d5e32da6bfe21a9659cc35d1f07bb config-baseline.channel.json
|
||||
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
|
||||
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
|
||||
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
|
||||
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user