mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
6 Commits
codex/tele
...
qa-delete-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62df4f5674 | ||
|
|
633ab545a1 | ||
|
|
606494a5cb | ||
|
|
dacd29616b | ||
|
|
fce216acf0 | ||
|
|
da5c13b24a |
@@ -1,44 +1,33 @@
|
||||
---
|
||||
name: channel-message-flows
|
||||
description: "Use when previewing local channel message flow fixtures."
|
||||
description: "Use when validating local channel message flow QA evidence."
|
||||
---
|
||||
|
||||
# Channel Message Flows
|
||||
|
||||
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
|
||||
Use this from the OpenClaw repo root to validate canned channel preview flows as deterministic QA evidence.
|
||||
|
||||
## Telegram
|
||||
|
||||
Native Telegram `sendMessageDraft` tool progress, then a final answer:
|
||||
Run the QA scenario:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow working-final \
|
||||
--duration-ms 20000
|
||||
pnpm openclaw qa suite \
|
||||
--scenario channel-message-flows \
|
||||
--output-dir .artifacts/qa-e2e/channel-message-flows
|
||||
```
|
||||
|
||||
Thinking preview, then a final answer:
|
||||
Run the focused Vitest proof:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow thinking-final
|
||||
node scripts/run-vitest.mjs \
|
||||
test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts \
|
||||
--reporter=verbose
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--account <accountId>`: Telegram account id when not using the default.
|
||||
- `--thread-id <id>`: Telegram forum topic/message thread id.
|
||||
- `--delay-ms <ms>`: Override preview update cadence.
|
||||
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
|
||||
- `--final-text <text>`: Override the durable final message.
|
||||
|
||||
## Notes
|
||||
|
||||
- `--target` is the numeric Telegram chat id.
|
||||
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
|
||||
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
|
||||
- Only `--channel telegram` is implemented for now.
|
||||
- `working-final` covers static `Working` status with sample tool progress before a durable final answer.
|
||||
- `thinking-final` covers formatted `Thinking` reasoning preview clearing before a durable final answer.
|
||||
- The QA scenario is deterministic and does not send live Telegram messages.
|
||||
- For live Telegram proof, use the Telegram Crabbox E2E proof workflow instead.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: discord-user-post
|
||||
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
|
||||
---
|
||||
|
||||
# Discord User Post
|
||||
|
||||
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
|
||||
existing logged-in session. This workflow represents the user directly.
|
||||
|
||||
## Prepare
|
||||
|
||||
1. Draft the complete final message outside Discord.
|
||||
2. Confirm the intended server and channel with the user when either is
|
||||
ambiguous.
|
||||
3. Open Discord and navigate to the exact destination without entering the
|
||||
message.
|
||||
4. Verify the visible server name, channel header, and logged-in account.
|
||||
|
||||
Do not infer the target from unrelated Discord content. Stop if Discord is not
|
||||
logged in, the account is wrong, or the exact destination cannot be verified.
|
||||
|
||||
## Confirm and Post
|
||||
|
||||
Posting is representational communication. Follow the `$computer-use`
|
||||
confirmation policy even when the user previously asked for an announcement:
|
||||
|
||||
1. Show the user the exact final body and verified destination.
|
||||
2. Request action-time confirmation before typing into Discord.
|
||||
3. After confirmation, enter the approved body unchanged.
|
||||
4. Visually inspect the composed message and destination again.
|
||||
5. Send once.
|
||||
|
||||
If the body or destination changes after confirmation, request confirmation
|
||||
again before sending.
|
||||
|
||||
## Verify
|
||||
|
||||
- Confirm the message appears once, from the user's account, in the intended
|
||||
channel.
|
||||
- Report the server, channel, and visible send result.
|
||||
- Do not edit, delete, react, or send a follow-up without the corresponding
|
||||
user instruction and confirmation.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
|
||||
or token for this workflow.
|
||||
- Never expose private Discord content or account details in public output.
|
||||
- Never send a draft, partial message, duplicate, or unreviewed attachment.
|
||||
- For Discord archive/history/search, use `$discrawl` instead.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Discord User Post"
|
||||
short_description: "Post approved messages through the logged-in Discord app"
|
||||
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."
|
||||
@@ -6,8 +6,7 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
|
||||
# OpenClaw Release Announcement
|
||||
|
||||
Use with `release-openclaw-maintainer` after a beta or stable release is live.
|
||||
Use with `$discord-user-post` when actually posting to Discord as the logged-in
|
||||
user.
|
||||
Use with `openclaw-discord` when actually posting to Discord.
|
||||
|
||||
## Evidence First
|
||||
|
||||
@@ -81,7 +80,6 @@ Fresh installs still point to `https://openclaw.ai`.
|
||||
|
||||
## Posting
|
||||
|
||||
When asked to post, use `$discord-user-post` to operate the logged-in Discord
|
||||
desktop app as the user. Resolve and visibly verify the exact server/channel,
|
||||
inspect the final body, and request action-time confirmation before entering or
|
||||
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.
|
||||
When asked to post, use the configured Discord workflow from
|
||||
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
|
||||
For public channels, inspect the final body before sending.
|
||||
|
||||
@@ -321,7 +321,6 @@ Upgrade with the beta channel.
|
||||
Before tagging or publishing, run:
|
||||
|
||||
```bash
|
||||
pnpm release:fast-pretag-check
|
||||
pnpm check:architecture
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
@@ -330,21 +329,6 @@ pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
|
||||
publishable plugin must have a non-empty package-root `README.md`, build its
|
||||
package-local runtime, and pass the npm and ClawHub release metadata checks
|
||||
before a tag or publish workflow can start. Do not defer README, entrypoint,
|
||||
or packed-artifact failures to postpublish verification.
|
||||
- Before tagging, require green CI for the exact release-candidate SHA, not an
|
||||
earlier branch SHA. Heal every related red CI, release-check, packaging, or
|
||||
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
|
||||
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
|
||||
npm preflight passed.
|
||||
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
|
||||
release `install-smoke` group or equivalent root Dockerfile build for the
|
||||
exact candidate SHA and require it to pass. The tag-triggered Docker Release
|
||||
workflow is post-tag publishing, not the first valid proof that the root
|
||||
Dockerfile can build.
|
||||
- Before tagging, diff publishable plugin package manifests against the last
|
||||
reachable stable/beta release tag. For every newly publishable package
|
||||
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
|
||||
@@ -660,10 +644,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
off, live OpenAI off, and regression failure off. Let it run in parallel
|
||||
with preflight and validation work.
|
||||
10. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
|
||||
to be green before tagging. Keep the remaining expensive Docker, Parallels,
|
||||
and published-package install/update lanes for after the beta is live unless
|
||||
the operator asks to run them before beta publication.
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
release blocker specifically requires it. For stable releases, include the
|
||||
mac app, signing, notarization, and appcast path.
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -1288,7 +1288,6 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_LOCAL_CHECK: "0"
|
||||
TASK: ${{ matrix.task }}
|
||||
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1298,10 +1297,6 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
|
||||
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
|
||||
fi
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1365,8 +1360,6 @@ jobs:
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1522,9 +1515,6 @@ jobs:
|
||||
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
;;
|
||||
|
||||
447
.github/workflows/ios-periphery-comment.yml
vendored
447
.github/workflows/ios-periphery-comment.yml
vendored
@@ -1,447 +0,0 @@
|
||||
name: iOS Periphery Dead Code Comment
|
||||
|
||||
on:
|
||||
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
|
||||
workflows: ["iOS Periphery Dead Code"]
|
||||
types: [completed]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment on PR
|
||||
runs-on: ubuntu-24.04
|
||||
if: >
|
||||
github.repository == 'openclaw/openclaw' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.name == 'iOS Periphery Dead Code'
|
||||
steps:
|
||||
- name: Upsert Periphery PR comment
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
|
||||
const run = context.payload.workflow_run;
|
||||
const pr = run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
core.info("No pull request attached to workflow_run.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const repository = `${owner}/${repo}`;
|
||||
if (run.repository?.full_name !== repository) {
|
||||
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (run.event !== "pull_request") {
|
||||
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
|
||||
return;
|
||||
}
|
||||
if (run.name !== "iOS Periphery Dead Code") {
|
||||
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const livePull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (livePull.data.state !== "open") {
|
||||
core.info(`Skipping closed PR #${pr.number}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.base?.repo?.full_name !== repository) {
|
||||
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
|
||||
return;
|
||||
}
|
||||
if (livePull.data.head?.sha !== run.head_sha) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
|
||||
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
|
||||
const scanSkipped =
|
||||
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
|
||||
if (scanSkipped) {
|
||||
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
|
||||
}
|
||||
|
||||
const artifacts = scanSkipped
|
||||
? []
|
||||
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const readReport = async () => {
|
||||
if (scanSkipped) {
|
||||
return;
|
||||
}
|
||||
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
|
||||
const artifact = artifacts.find((item) => item.name === artifactName);
|
||||
if (!artifact) {
|
||||
core.warning(`No ${artifactName} artifact found.`);
|
||||
return;
|
||||
}
|
||||
if (artifact.expired) {
|
||||
core.warning(`${artifactName} artifact expired.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxArchiveBytes = 1024 * 1024;
|
||||
const archiveSize = Number(artifact.size_in_bytes);
|
||||
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
|
||||
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const archive = await github.rest.actions.downloadArtifact({
|
||||
owner,
|
||||
repo,
|
||||
artifact_id: artifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
|
||||
const archivePath = path.join(dir, "artifact.zip");
|
||||
const archiveBuffer = Buffer.from(archive.data);
|
||||
fs.writeFileSync(archivePath, archiveBuffer);
|
||||
|
||||
const allowedArtifactFiles = new Set([
|
||||
"periphery.json",
|
||||
"periphery.status",
|
||||
"periphery.stderr.log",
|
||||
"periphery.stdout.json",
|
||||
"should-fail.txt",
|
||||
]);
|
||||
const maxEntries = allowedArtifactFiles.size;
|
||||
const maxEntryBytes = 2 * 1024 * 1024;
|
||||
const maxTotalBytes = 4 * 1024 * 1024;
|
||||
|
||||
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
|
||||
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
|
||||
const findEndOfCentralDirectoryOffset = () => {
|
||||
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
|
||||
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
|
||||
if (readUInt32(offset) === 0x06054b50) {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
|
||||
if (endOfCentralDirectoryOffset < 0) {
|
||||
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
|
||||
return;
|
||||
}
|
||||
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
|
||||
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
|
||||
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
|
||||
if (entryCount < 1 || entryCount > maxEntries) {
|
||||
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
|
||||
readUInt32(centralDirectoryOffset) !== 0x02014b50
|
||||
) {
|
||||
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = new Map();
|
||||
let totalUncompressedSize = 0;
|
||||
let offset = centralDirectoryOffset;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
|
||||
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compressionMethod = readUInt16(offset + 10);
|
||||
const generalPurposeBitFlag = readUInt16(offset + 8);
|
||||
const compressedSize = readUInt32(offset + 20);
|
||||
const uncompressedSize = readUInt32(offset + 24);
|
||||
const fileNameLength = readUInt16(offset + 28);
|
||||
const extraLength = readUInt16(offset + 30);
|
||||
const commentLength = readUInt16(offset + 32);
|
||||
const externalAttributes = readUInt32(offset + 38);
|
||||
const nameStart = offset + 46;
|
||||
const nameEnd = nameStart + fileNameLength;
|
||||
const nextOffset = nameEnd + extraLength + commentLength;
|
||||
if (nextOffset > archiveBuffer.length) {
|
||||
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
|
||||
const mode = externalAttributes >>> 16;
|
||||
const fileType = mode & 0o170000;
|
||||
const isRegularFile = fileType === 0 || fileType === 0o100000;
|
||||
const invalidName =
|
||||
!allowedArtifactFiles.has(name) ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\") ||
|
||||
name.includes("..") ||
|
||||
path.isAbsolute(name);
|
||||
if (invalidName) {
|
||||
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (!isRegularFile || name.endsWith("/")) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
|
||||
return;
|
||||
}
|
||||
if (entries.has(name)) {
|
||||
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
|
||||
return;
|
||||
}
|
||||
if (![0, 8].includes(compressionMethod)) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
|
||||
return;
|
||||
}
|
||||
if ((generalPurposeBitFlag & 0x1) !== 0) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
|
||||
return;
|
||||
}
|
||||
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
totalUncompressedSize += uncompressedSize;
|
||||
if (totalUncompressedSize > maxTotalBytes) {
|
||||
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
entries.set(name, { uncompressedSize });
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const files = new Map();
|
||||
for (const [name, entry] of entries) {
|
||||
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
|
||||
timeout: 5000,
|
||||
});
|
||||
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
|
||||
return;
|
||||
}
|
||||
files.set(name, contents);
|
||||
}
|
||||
|
||||
const read = (name) => {
|
||||
return files.get(name) ?? "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
const validFindings =
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every(
|
||||
(finding) =>
|
||||
finding !== null &&
|
||||
typeof finding === "object" &&
|
||||
!Array.isArray(finding),
|
||||
);
|
||||
if (validFindings) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { findings, status };
|
||||
};
|
||||
const report = await readReport();
|
||||
const status = report?.status ?? 1;
|
||||
const findings = report?.findings ?? null;
|
||||
|
||||
const sanitizeCell = (value) => {
|
||||
const normalized = String(value ?? "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
const maxEncodedLength = 180;
|
||||
let escaped = "";
|
||||
for (const character of normalized) {
|
||||
const encoded =
|
||||
character === "`"
|
||||
? "'"
|
||||
: character === "|"
|
||||
? "\\|"
|
||||
: character;
|
||||
if (escaped.length + encoded.length > maxEncodedLength) {
|
||||
break;
|
||||
}
|
||||
escaped += encoded;
|
||||
}
|
||||
return `\`${escaped || "-"}\``;
|
||||
};
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
return {
|
||||
file: file ? `apps/ios/${file}` : "",
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
let mode = "failure";
|
||||
let body = `${marker}\n`;
|
||||
if (scanSkipped) {
|
||||
mode = "skipped";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
|
||||
].join("\n");
|
||||
} else if (findings === null) {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
mode = "success";
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
const shown = rows.slice(0, 50);
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
|
||||
"",
|
||||
"| File | Line | Kind | Name |",
|
||||
"| --- | ---: | --- | --- |",
|
||||
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
|
||||
rows.length > shown.length ? "" : null,
|
||||
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
|
||||
].filter(Boolean).join("\n");
|
||||
} else {
|
||||
body += [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
body += "\n";
|
||||
const maxCommentChars = 60_000;
|
||||
if (body.length > maxCommentChars) {
|
||||
body = [
|
||||
marker,
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
if (!existing && ["skipped", "success"].includes(mode)) {
|
||||
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPull = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (
|
||||
currentPull.data.state !== "open" ||
|
||||
currentPull.data.base?.repo?.full_name !== repository ||
|
||||
currentPull.data.head?.sha !== run.head_sha
|
||||
) {
|
||||
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: run.workflow_id,
|
||||
event: "pull_request",
|
||||
head_sha: run.head_sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const supersedingRun = workflowRuns.find(
|
||||
(candidate) =>
|
||||
(candidate.id === run.id ||
|
||||
candidate.pull_requests?.some(
|
||||
(candidatePull) => candidatePull.number === pr.number,
|
||||
)) &&
|
||||
(candidate.run_number > run.run_number ||
|
||||
(candidate.run_number === run.run_number &&
|
||||
candidate.run_attempt > run.run_attempt)),
|
||||
);
|
||||
if (supersedingRun) {
|
||||
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: livePull.data.number,
|
||||
body,
|
||||
});
|
||||
229
.github/workflows/ios-periphery.yml
vendored
229
.github/workflows/ios-periphery.yml
vendored
@@ -1,229 +0,0 @@
|
||||
name: iOS Periphery Dead Code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
name: Detect iOS scan scope
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should-scan: ${{ steps.scope.outputs.should-scan }}
|
||||
steps:
|
||||
- name: Detect changed paths
|
||||
id: scope
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
core.setOutput("should-scan", "true");
|
||||
return;
|
||||
}
|
||||
if (context.payload.pull_request?.draft) {
|
||||
core.setOutput("should-scan", "false");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const isScanPath = (filename) =>
|
||||
typeof filename === "string" && (
|
||||
filename.startsWith("apps/ios/") ||
|
||||
filename === ".github/workflows/ios-periphery.yml" ||
|
||||
filename === ".github/workflows/ios-periphery-comment.yml" ||
|
||||
filename === "config/swiftformat" ||
|
||||
filename === "config/swiftlint.yml"
|
||||
);
|
||||
const shouldScan = files.some(
|
||||
({ filename, previous_filename: previousFilename }) =>
|
||||
isScanPath(filename) || isScanPath(previousFilename)
|
||||
);
|
||||
core.setOutput("should-scan", String(shouldScan));
|
||||
|
||||
scan:
|
||||
name: Scan iOS dead code
|
||||
needs: scope
|
||||
if: ${{ needs.scope.outputs.should-scan == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Verify Xcode
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftformat swiftlint periphery
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/ios-configure-signing.sh
|
||||
./scripts/ios-write-version-xcconfig.sh
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: Run Periphery
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output_dir="$RUNNER_TEMP/ios-periphery"
|
||||
mkdir -p "$output_dir"
|
||||
cd apps/ios
|
||||
set +e
|
||||
periphery scan \
|
||||
--config .periphery.yml \
|
||||
--strict \
|
||||
--format json \
|
||||
--write-results "$output_dir/periphery.json" \
|
||||
>"$output_dir/periphery.stdout.json" \
|
||||
2>"$output_dir/periphery.stderr.log"
|
||||
periphery_status="$?"
|
||||
set -e
|
||||
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
|
||||
if [ ! -s "$output_dir/periphery.json" ]; then
|
||||
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
|
||||
fi
|
||||
|
||||
- name: Build Periphery report
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery");
|
||||
const read = (name) => {
|
||||
const file = path.join(outputDir, name);
|
||||
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
};
|
||||
|
||||
const status = Number(read("periphery.status").trim() || "1");
|
||||
let findings = null;
|
||||
for (const name of ["periphery.json", "periphery.stdout.json"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(read(name));
|
||||
if (Array.isArray(parsed)) {
|
||||
findings = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const escapeCommandData = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("%", "%25")
|
||||
.replaceAll("\r", "%0D")
|
||||
.replaceAll("\n", "%0A");
|
||||
const escapeCommandProperty = (value) =>
|
||||
escapeCommandData(value)
|
||||
.replaceAll(":", "%3A")
|
||||
.replaceAll(",", "%2C");
|
||||
|
||||
const rows = (findings ?? []).map((finding) => {
|
||||
const location = String(finding.location ?? "");
|
||||
const [file, line] = location.split(":");
|
||||
const repoFile = file ? `apps/ios/${file}` : "";
|
||||
return {
|
||||
file: repoFile,
|
||||
line: line || "",
|
||||
kind: String(finding.kind ?? ""),
|
||||
name: String(finding.name ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.file) continue;
|
||||
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
|
||||
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
|
||||
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
|
||||
}
|
||||
|
||||
let shouldFail = "1";
|
||||
let summary = "";
|
||||
|
||||
if (findings === null) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
} else if (rows.length === 0 && status === 0) {
|
||||
shouldFail = "0";
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"No dead Swift code found.",
|
||||
].join("\n");
|
||||
} else if (rows.length > 0) {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
|
||||
].join("\n");
|
||||
} else {
|
||||
summary = [
|
||||
"### iOS Periphery",
|
||||
"",
|
||||
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload Periphery report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"
|
||||
20
.github/workflows/openclaw-performance.yml
vendored
20
.github/workflows/openclaw-performance.yml
vendored
@@ -56,7 +56,6 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
OCM_LINUX_X64_SHA256: b849b8de5d77e97e0df9319703254ae95e29d7f26a7552ea79bf173ff110ea0a
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
|
||||
@@ -188,20 +187,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
KOVA_SRC="${RUNNER_TEMP}/kova-src"
|
||||
echo "KOVA_SRC=$KOVA_SRC" >> "$GITHUB_ENV"
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")" "${RUNNER_TEMP}/ocm-install"
|
||||
|
||||
ocm_archive="${RUNNER_TEMP}/ocm-${OCM_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused \
|
||||
-o "$ocm_archive" \
|
||||
"https://github.com/shakkernerd/ocm/releases/download/${OCM_VERSION}/ocm-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "${OCM_LINUX_X64_SHA256} ${ocm_archive}" | sha256sum -c -
|
||||
tar -xzf "$ocm_archive" -C "${RUNNER_TEMP}/ocm-install"
|
||||
install -m 0755 "${RUNNER_TEMP}/ocm-install/ocm" "$HOME/.local/bin/ocm"
|
||||
|
||||
git init -b main "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" remote add origin "https://github.com/${KOVA_REPOSITORY}.git"
|
||||
git -C "$KOVA_SRC" fetch --filter=blob:none --depth 1 origin "$KOVA_REF"
|
||||
git -C "$KOVA_SRC" checkout --detach FETCH_HEAD
|
||||
mkdir -p "$HOME/.local/bin" "$(dirname "$KOVA_SRC")"
|
||||
curl -fsSL https://raw.githubusercontent.com/shakkernerd/ocm/main/install.sh \
|
||||
| bash -s -- --version "$OCM_VERSION" --prefix "$HOME/.local" --force
|
||||
git clone --filter=blob:none "https://github.com/${KOVA_REPOSITORY}.git" "$KOVA_SRC"
|
||||
git -C "$KOVA_SRC" checkout "$KOVA_REF"
|
||||
cat > "$HOME/.local/bin/kova" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
export KOVA_HOME="${KOVA_HOME}"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -127,8 +127,6 @@ mantis/
|
||||
!.agents/skills/clawdtributor/**
|
||||
!.agents/skills/control-ui-e2e/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/discord-user-post/
|
||||
!.agents/skills/discord-user-post/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/technical-documentation/
|
||||
|
||||
@@ -30,9 +30,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
|
||||
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
|
||||
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
|
||||
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
|
||||
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
|
||||
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
|
||||
# installed prod graph in the same step that runs offline prune.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
|
||||
CI=true pnpm prune --prod \
|
||||
--config.offline=true \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
|
||||
@@ -443,7 +443,6 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -486,14 +485,6 @@ class NodeRuntime(
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun subscribeOperatorSessionEvents() {
|
||||
try {
|
||||
operatorSession.request("sessions.subscribe", null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private val nodeSession =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
|
||||
@@ -311,6 +311,7 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -320,6 +321,7 @@ class ChatController(
|
||||
scope.launch { pollHealthIfNeeded(force = false) }
|
||||
}
|
||||
"health" -> {
|
||||
// If we receive a health snapshot, the gateway is reachable.
|
||||
_healthOk.value = true
|
||||
}
|
||||
"seqGap" -> {
|
||||
@@ -330,17 +332,6 @@ class ChatController(
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleChatEvent(payloadJson)
|
||||
}
|
||||
"sessions.changed" -> {
|
||||
if (payloadJson.isNullOrBlank()) {
|
||||
refreshSessionsForCurrentWindow()
|
||||
} else {
|
||||
handleSessionsChangedEvent(payloadJson)
|
||||
}
|
||||
}
|
||||
"session.message" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleSessionMessageEvent(payloadJson)
|
||||
}
|
||||
"agent" -> {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
handleAgentEvent(payloadJson)
|
||||
@@ -362,7 +353,6 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -398,10 +388,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSessionsForCurrentWindow() {
|
||||
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastHealthPollAtMs
|
||||
@@ -471,7 +457,6 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
updateSessionFromHistory(history)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
@@ -487,31 +472,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionsChangedEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
if (payload["reason"].asStringOrNull() == "delete") {
|
||||
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
|
||||
return
|
||||
}
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
} else {
|
||||
refreshSessionsForCurrentWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionMessageEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val entry = parseEventSessionEntry(payload)
|
||||
if (entry != null) {
|
||||
upsertSessionEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
@@ -640,7 +600,6 @@ class ChatController(
|
||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||
val sid = root["sessionId"].asStringOrNull()
|
||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
|
||||
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||
|
||||
val messages =
|
||||
@@ -663,69 +622,20 @@ class ChatController(
|
||||
sessionId = sid,
|
||||
thinkingLevel = thinkingLevel,
|
||||
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||
sessionInfo = sessionInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
|
||||
}
|
||||
|
||||
private fun parseSessionEntry(
|
||||
obj: JsonObject?,
|
||||
fallbackKey: String? = null,
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
updatedAtMs = obj["updatedAt"].asLongOrNull(),
|
||||
displayName = obj["displayName"].asStringOrNull()?.trim(),
|
||||
totalTokens = obj["totalTokens"].asLongOrNull(),
|
||||
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
|
||||
contextTokens = obj["contextTokens"].asLongOrNull(),
|
||||
hasContextUsageMetadata =
|
||||
"totalTokens" in obj ||
|
||||
"totalTokensFresh" in obj ||
|
||||
"contextTokens" in obj,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateSessionFromHistory(history: ChatHistory) {
|
||||
val info = history.sessionInfo ?: return
|
||||
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
|
||||
}
|
||||
|
||||
private fun upsertSessionEntry(
|
||||
entry: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
) {
|
||||
val current = _sessions.value
|
||||
val index = current.indexOfFirst { it.key == entry.key }
|
||||
_sessions.value =
|
||||
if (index >= 0) {
|
||||
current.toMutableList().also {
|
||||
it[index] =
|
||||
mergeChatSessionEntry(
|
||||
existing = it[index],
|
||||
next = entry,
|
||||
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
listOf(entry) + current
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSessionEntry(sessionKey: String?) {
|
||||
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
return sessions.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
@@ -947,44 +857,3 @@ private fun JsonElement?.asLongOrNull(): Long? =
|
||||
is JsonPrimitive -> content.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content.toBooleanStrictOrNull()
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun mergeChatSessionEntry(
|
||||
existing: ChatSessionEntry,
|
||||
next: ChatSessionEntry,
|
||||
preserveExistingContextUsageWithoutTotal: Boolean = false,
|
||||
): ChatSessionEntry {
|
||||
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
|
||||
return existing.copy(
|
||||
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
|
||||
displayName = next.displayName ?: existing.displayName,
|
||||
totalTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokens
|
||||
next.hasContextUsageMetadata -> next.totalTokens
|
||||
else -> null
|
||||
},
|
||||
totalTokensFresh =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.totalTokensFresh
|
||||
next.hasContextUsageMetadata -> next.totalTokensFresh
|
||||
else -> null
|
||||
},
|
||||
contextTokens =
|
||||
when {
|
||||
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
|
||||
next.hasContextUsageMetadata -> next.contextTokens
|
||||
else -> null
|
||||
},
|
||||
hasContextUsageMetadata =
|
||||
when {
|
||||
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
|
||||
else -> next.hasContextUsageMetadata
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
val totalTokens: Long? = null,
|
||||
val totalTokensFresh: Boolean? = null,
|
||||
val contextTokens: Long? = null,
|
||||
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -54,7 +50,6 @@ data class ChatHistory(
|
||||
val sessionId: String?,
|
||||
val thinkingLevel: String?,
|
||||
val messages: List<ChatMessage>,
|
||||
val sessionInfo: ChatSessionEntry? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,7 +74,6 @@ import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
|
||||
@Composable
|
||||
@@ -96,7 +95,6 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -198,7 +196,6 @@ fun ChatScreen(
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
@@ -688,7 +685,6 @@ private fun ChatComposer(
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
@@ -703,11 +699,7 @@ private fun ChatComposer(
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(
|
||||
thinkingLevel = thinkingLevel,
|
||||
contextUsage = contextUsage,
|
||||
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
|
||||
)
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
@@ -743,10 +735,8 @@ private fun ChatComposer(
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
contextUsage: ChatContextUsage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -765,13 +755,7 @@ private fun ChatContextMeter(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -784,7 +768,7 @@ private fun ChatContextMeter(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(contextFraction)
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
@@ -918,32 +902,6 @@ private fun isActiveSessionChoice(
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
internal data class ChatContextUsage(
|
||||
val totalTokens: Long?,
|
||||
val totalTokensFresh: Boolean?,
|
||||
val contextTokens: Long?,
|
||||
)
|
||||
|
||||
internal fun resolveChatContextUsage(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): ChatContextUsage {
|
||||
val entry =
|
||||
sessions.firstOrNull {
|
||||
isActiveSessionChoice(
|
||||
choiceKey = it.key,
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
contextTokens = entry?.contextTokens,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
@@ -1000,29 +958,17 @@ private fun nextThinkingValue(value: String): String =
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
|
||||
if (usage.totalTokensFresh == false) return null
|
||||
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
|
||||
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
|
||||
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
|
||||
}
|
||||
|
||||
internal fun contextMeterLabel(
|
||||
usage: ChatContextUsage,
|
||||
thinkingLevel: String,
|
||||
): String {
|
||||
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
|
||||
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
|
||||
}
|
||||
|
||||
internal fun contextMeterThinkingLabel(value: String): String =
|
||||
/** Maps thinking presets to the visual context meter fill fraction. */
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
/** Quick markdown detector used to avoid routing plain chat text through the markdown renderer. */
|
||||
|
||||
@@ -59,96 +59,4 @@ class ChatControllerSessionPolicyTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
hasContextUsageMetadata = false,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals("agent:main:phone", merged.key)
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(null, merged.totalTokens)
|
||||
assertEquals(null, merged.totalTokensFresh)
|
||||
assertEquals(null, merged.contextTokens)
|
||||
assertFalse(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone renamed",
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 120_000L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeChatSessionEntry(
|
||||
existing = existing,
|
||||
next = next,
|
||||
preserveExistingContextUsageWithoutTotal = true,
|
||||
)
|
||||
|
||||
assertEquals(2L, merged.updatedAtMs)
|
||||
assertEquals("Phone renamed", merged.displayName)
|
||||
assertEquals(41_000L, merged.totalTokens)
|
||||
assertEquals(true, merged.totalTokensFresh)
|
||||
assertEquals(120_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
|
||||
val existing =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 1L,
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
val next =
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:phone",
|
||||
updatedAtMs = 2L,
|
||||
totalTokens = 82_000L,
|
||||
totalTokensFresh = false,
|
||||
contextTokens = 100_000L,
|
||||
)
|
||||
|
||||
val merged = mergeChatSessionEntry(existing, next)
|
||||
|
||||
assertEquals(82_000L, merged.totalTokens)
|
||||
assertEquals(false, merged.totalTokensFresh)
|
||||
assertEquals(100_000L, merged.contextTokens)
|
||||
assertTrue(merged.hasContextUsageMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ChatContextMeterTest {
|
||||
@Test
|
||||
fun contextMeterUsesActiveSessionTokenBudget() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:mobile:test-device",
|
||||
updatedAtMs = 2L,
|
||||
displayName = "Phone",
|
||||
totalTokens = 1_250L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 5_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "agent:main:mobile:test-device",
|
||||
mainSessionKey = "main",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
|
||||
assertEquals(0.25f, contextMeterWidth(usage))
|
||||
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterResolvesCanonicalMainAlias() {
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(
|
||||
key = "agent:main:node-phone",
|
||||
updatedAtMs = 1L,
|
||||
displayName = "Main",
|
||||
totalTokens = 41_000L,
|
||||
totalTokensFresh = true,
|
||||
contextTokens = 100_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val usage =
|
||||
resolveChatContextUsage(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:main:node-phone",
|
||||
sessions = sessions,
|
||||
)
|
||||
|
||||
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
|
||||
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
|
||||
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterClampsOverfullSessions() {
|
||||
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
|
||||
|
||||
assertEquals(1.0f, contextMeterWidth(usage))
|
||||
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextMeterDoesNotDisplayStaleTokenUsage() {
|
||||
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
|
||||
|
||||
assertNull(contextMeterWidth(usage))
|
||||
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
project: OpenClaw.xcodeproj
|
||||
schemes:
|
||||
- OpenClaw
|
||||
retain_codable_properties: true
|
||||
retain_swift_ui_previews: true
|
||||
retain_objc_accessible: true
|
||||
retain_unused_protocol_func_params: true
|
||||
retain_assign_only_properties: true
|
||||
relative_results: true
|
||||
disable_update_check: true
|
||||
report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchExtension/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
@@ -58,11 +58,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.12 - 2026-05-12
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
## 2026.5.10 - 2026-05-10
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
|
||||
|
||||
@@ -128,7 +128,7 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.19 - 2026-04-19
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
## 2026.4.18 - 2026-04-18
|
||||
|
||||
@@ -136,11 +136,11 @@ Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
## 2026.4.14 - 2026-04-14
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
## 2026.4.12 - 2026-04-12
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"teamId": "FWJYW4S8P8",
|
||||
"signingRepo": "git@github.com:openclaw/ios-signing.git",
|
||||
"certificateType": "IOS_DISTRIBUTION",
|
||||
"profileType": "IOS_APP_STORE",
|
||||
"targets": [
|
||||
{
|
||||
"target": "OpenClaw",
|
||||
"displayName": "OpenClaw",
|
||||
"bundleId": "ai.openclawfoundation.app",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS"]
|
||||
},
|
||||
{
|
||||
"target": "OpenClawShareExtension",
|
||||
"displayName": "OpenClaw Share",
|
||||
"bundleId": "ai.openclawfoundation.app.share",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_SHARE_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawActivityWidget",
|
||||
"displayName": "OpenClaw Activity Widget",
|
||||
"bundleId": "ai.openclawfoundation.app.activitywidget",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_ACTIVITY_WIDGET_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.activitywidget",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchApp",
|
||||
"displayName": "OpenClaw Watch App",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
|
||||
"capabilities": []
|
||||
},
|
||||
{
|
||||
"target": "OpenClawWatchExtension",
|
||||
"displayName": "OpenClaw Watch Extension",
|
||||
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
|
||||
"capabilities": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
// Shared iOS signing defaults for local development + CI.
|
||||
#include "Version.xcconfig"
|
||||
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
|
||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||
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_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -20,7 +18,7 @@ OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
#include? "../LocalSigning.xcconfig"
|
||||
|
||||
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
|
||||
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
|
||||
|
||||
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
// This file is only an example and should stay committed.
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
|
||||
@@ -4,8 +4,8 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
|
||||
|
||||
## Distribution Status
|
||||
|
||||
- Public distribution: App Store Connect app created; production signing is configured through the App Store release Fastlane path.
|
||||
- Internal TestFlight distribution: uses the same App Store distribution archive uploaded to App Store Connect.
|
||||
- Public distribution: not available.
|
||||
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
|
||||
- Local/manual deploy from source via Xcode remains the default development path.
|
||||
|
||||
## Super-Alpha Disclaimer
|
||||
@@ -47,7 +47,7 @@ Shortcut command (same flow + open project):
|
||||
pnpm ios:open
|
||||
```
|
||||
|
||||
## App Store Release Flow
|
||||
## Local Beta Release Flow
|
||||
|
||||
Prereqs:
|
||||
|
||||
@@ -55,82 +55,51 @@ Prereqs:
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- `fastlane`
|
||||
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
|
||||
- `asc` CLI authenticated for the canonical OpenClaw team
|
||||
- Release-owner access to the encrypted signing repo password (`ASC_MATCH_PASSWORD`)
|
||||
- App Store Connect app already created for `ai.openclawfoundation.app`
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
|
||||
- Apple account signed into Xcode for automatic signing/provisioning
|
||||
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
|
||||
|
||||
Release behavior:
|
||||
|
||||
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- `asc` owns one-time Developer Portal setup and encrypted signing sync. Fastlane owns release handling after those assets exist.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
- The pinned iOS version must use CalVer like `2026.4.10`.
|
||||
- That pinned value becomes:
|
||||
- `CFBundleShortVersionString = 2026.4.10`
|
||||
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.4.10`
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Relay behavior for App Store builds:
|
||||
Relay behavior for beta builds:
|
||||
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:signing:plan
|
||||
pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
|
||||
```
|
||||
|
||||
Release-owner secrets:
|
||||
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `ASC_MATCH_PASSWORD`.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means revoking/replacing the Developer Portal certificate or profile with `asc`, then pushing a fresh encrypted sync state.
|
||||
|
||||
Prepare the generated release xcconfig/project without archiving:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:prepare -- --build-number 7
|
||||
```
|
||||
|
||||
Archive without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:archive
|
||||
pnpm ios:beta:archive
|
||||
```
|
||||
|
||||
Archive and upload to App Store Connect:
|
||||
Archive and upload to TestFlight:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
If you need to force a specific build number:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload -- --build-number 7
|
||||
pnpm ios:beta -- --build-number 7
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to an App Store Connect upload.
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
|
||||
|
||||
1. Confirm Fastlane auth is set up:
|
||||
|
||||
@@ -150,50 +119,38 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Confirm the App Store Connect app and Apple Developer identifiers/capabilities exist for:
|
||||
- `ai.openclawfoundation.app`
|
||||
- `ai.openclawfoundation.app.share`
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
- `ai.openclawfoundation.app.watchkitapp.extension`
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted signing assets to the shared private repo.
|
||||
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
6. Upload the build:
|
||||
5. Upload the beta:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
7. Expected behavior:
|
||||
6. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- resolves the next TestFlight build number for that short version
|
||||
- generates `apps/ios/build/BetaRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
- uploads the IPA to TestFlight
|
||||
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
7. Expected outputs after a successful run:
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -219,7 +176,7 @@ Recommended flow:
|
||||
1. Keep `apps/ios/version.json` pinned to the current train version.
|
||||
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
|
||||
3. Run `pnpm ios:version:sync` after changelog changes.
|
||||
4. Upload more TestFlight builds with `pnpm ios:release:upload`.
|
||||
4. Upload more TestFlight builds with `pnpm ios:beta`.
|
||||
5. Let Fastlane bump only the numeric build number.
|
||||
|
||||
### Starting the next production release train
|
||||
@@ -232,7 +189,7 @@ pnpm ios:version:pin -- --from-gateway
|
||||
|
||||
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
|
||||
3. Run `pnpm ios:version:sync`.
|
||||
4. Submit the first App Store Connect build for that newly pinned version.
|
||||
4. Submit the first TestFlight build for that newly pinned version.
|
||||
5. Keep iterating on that same version until the release candidate is ready.
|
||||
|
||||
See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
@@ -240,9 +197,9 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
## APNs Expectations For Local/Manual Builds
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||
- 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.
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
@@ -362,7 +319,7 @@ Automatic wake/reconnect hardening:
|
||||
5. If network path is unclear:
|
||||
- switch to manual host/port + TLS in Gateway Advanced settings
|
||||
6. In Xcode console, filter for subsystem/category signals:
|
||||
- `ai.openclawfoundation.app`
|
||||
- `ai.openclaw.ios`
|
||||
- `GatewayDiag`
|
||||
- `APNs registration failed`
|
||||
7. Validate background expectations:
|
||||
|
||||
@@ -17,7 +17,7 @@ final class ShareViewController: UIViewController {
|
||||
var attachments: [ShareAttachment]
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension")
|
||||
private var statusLabel: UILabel?
|
||||
private let draftTextView = UITextView()
|
||||
private let sendButton = UIButton(type: .system)
|
||||
|
||||
@@ -5,21 +5,16 @@
|
||||
#include "Config/Version.xcconfig"
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.client.share
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
|
||||
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
|
||||
OPENCLAW_WATCH_APP_PROFILE =
|
||||
OPENCLAW_WATCH_EXTENSION_PROFILE =
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.client Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.client.share Development
|
||||
|
||||
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
|
||||
// so later assignments in local files override the defaults above.
|
||||
|
||||
@@ -14,50 +14,7 @@ enum AppleReviewDemoMode {
|
||||
}
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
LocalChatFixture.appleReviewDemo.agents
|
||||
}
|
||||
}
|
||||
|
||||
enum ScreenshotFixtureMode {
|
||||
static let gatewayName = "OpenClaw Gateway"
|
||||
static let gatewayAddress = "Mac Studio on local network"
|
||||
static let gatewayID = "screenshot-fixture-gateway"
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
LocalChatFixture.appScreenshots.agents
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalChatFixture {
|
||||
let sessionKey: String
|
||||
let sessionIDPrefix: String
|
||||
let displayName: String
|
||||
let subject: String
|
||||
let workspace: String
|
||||
let modelProvider: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let responsePrefix: String
|
||||
let seedMessages: [String]
|
||||
let agents: [AgentSummary]
|
||||
|
||||
static let appleReviewDemo = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "apple-review-demo",
|
||||
displayName: "Apple Review Demo",
|
||||
subject: "Gateway review flow",
|
||||
workspace: "Apple Review Demo",
|
||||
modelProvider: "demo",
|
||||
modelID: "local-demo",
|
||||
modelName: "Apple Review Demo",
|
||||
responsePrefix: "Demo mode is active.",
|
||||
seedMessages: [
|
||||
"""
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
[
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Main",
|
||||
@@ -68,70 +25,12 @@ struct LocalChatFixture {
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium"],
|
||||
thinkingdefault: "auto"),
|
||||
])
|
||||
|
||||
static let appScreenshots = LocalChatFixture(
|
||||
sessionKey: "main",
|
||||
sessionIDPrefix: "screenshot-fixture",
|
||||
displayName: "Molty",
|
||||
subject: "Mobile command center",
|
||||
workspace: "OpenClaw",
|
||||
modelProvider: "openai",
|
||||
modelID: "gpt-5.5",
|
||||
modelName: "GPT-5.5",
|
||||
responsePrefix: "OpenClaw is connected to your gateway.",
|
||||
seedMessages: [
|
||||
"""
|
||||
OpenClaw is connected to your gateway. I can coordinate agents, inspect project context, and prepare \
|
||||
actions from your phone.
|
||||
""",
|
||||
"""
|
||||
The Molty agent is ready. Recent context, voice controls, and gateway settings are available \
|
||||
across the app.
|
||||
""",
|
||||
],
|
||||
agents: [
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Molty",
|
||||
identity: ["emoji": AnyCodable("M")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
AgentSummary(
|
||||
id: "research",
|
||||
name: "Research",
|
||||
identity: ["emoji": AnyCodable("RS")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "medium"),
|
||||
AgentSummary(
|
||||
id: "automation",
|
||||
name: "Automation",
|
||||
identity: ["emoji": AnyCodable("AU")],
|
||||
workspace: "OpenClaw",
|
||||
model: ["provider": AnyCodable("openai"), "model": AnyCodable("gpt-5.5")],
|
||||
agentruntime: ["kind": AnyCodable("gateway")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium", "high"],
|
||||
thinkingdefault: "auto"),
|
||||
])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
private let fixture: LocalChatFixture
|
||||
private let store: LocalFixtureChatStore
|
||||
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.store = LocalFixtureChatStore(fixture: fixture)
|
||||
}
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let store = AppleReviewDemoChatStore()
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
@@ -148,9 +47,9 @@ struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: self.fixture.modelID,
|
||||
name: self.fixture.modelName,
|
||||
provider: self.fixture.modelProvider,
|
||||
modelID: "local-demo",
|
||||
name: "Apple Review Demo",
|
||||
provider: "demo",
|
||||
contextWindow: 128_000),
|
||||
]
|
||||
}
|
||||
@@ -202,102 +101,26 @@ struct LocalFixtureChatTransport: OpenClawChatTransport {
|
||||
func compactSession(sessionKey _: String) async throws {}
|
||||
}
|
||||
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let transport = LocalFixtureChatTransport(fixture: .appleReviewDemo)
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
try await self.transport.createSession(key: key, label: label, parentSessionKey: parentSessionKey)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.transport.requestHistory(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
try await self.transport.listModels()
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.transport.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
try await self.transport.abortRun(sessionKey: sessionKey, runId: runId)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.transport.listSessions(limit: limit)
|
||||
}
|
||||
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||
try await self.transport.setSessionModel(sessionKey: sessionKey, model: model)
|
||||
}
|
||||
|
||||
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws {
|
||||
try await self.transport.setSessionThinking(sessionKey: sessionKey, thinkingLevel: thinkingLevel)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await self.transport.requestHealth(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool {
|
||||
await self.transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.transport.events()
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
try await self.transport.setActiveSessionKey(sessionKey)
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
try await self.transport.resetSession(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
try await self.transport.compactSession(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private actor LocalFixtureChatStore {
|
||||
private let fixture: LocalChatFixture
|
||||
private actor AppleReviewDemoChatStore {
|
||||
private let sessionKey = "main"
|
||||
private var messages: [OpenClawChatMessage]
|
||||
|
||||
init(fixture: LocalChatFixture) {
|
||||
self.fixture = fixture
|
||||
self.messages = Self.seedMessages(fixture: fixture)
|
||||
init() {
|
||||
self.messages = AppleReviewDemoChatStore.seedMessages()
|
||||
}
|
||||
|
||||
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
|
||||
try Self.decode(
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
|
||||
as: OpenClawChatCreateSessionResponse.self)
|
||||
}
|
||||
|
||||
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
|
||||
return try Self.decode(
|
||||
HistoryPayload(
|
||||
sessionKey: normalizedSessionKey,
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
|
||||
sessionId: "apple-review-demo-\(normalizedSessionKey)",
|
||||
messages: self.messages,
|
||||
thinkingLevel: "auto"),
|
||||
as: OpenClawChatHistoryPayload.self)
|
||||
@@ -312,8 +135,9 @@ private actor LocalFixtureChatStore {
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
\(self.fixture.responsePrefix) I can help with \(subject), summarize current project context, \
|
||||
prepare agent actions, and keep the mobile workflow connected to the gateway.
|
||||
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
|
||||
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
|
||||
OpenClaw Gateway.
|
||||
""",
|
||||
timestamp: now + 1))
|
||||
return try Self.decode(
|
||||
@@ -323,15 +147,15 @@ private actor LocalFixtureChatStore {
|
||||
|
||||
func sessions() throws -> OpenClawChatSessionsListResponse {
|
||||
let entry = OpenClawChatSessionEntry(
|
||||
key: self.fixture.sessionKey,
|
||||
key: self.sessionKey,
|
||||
kind: "chat",
|
||||
displayName: self.fixture.displayName,
|
||||
displayName: "Apple Review Demo",
|
||||
surface: "ios",
|
||||
subject: self.fixture.subject,
|
||||
subject: "Gateway review flow",
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
|
||||
sessionId: "apple-review-demo-main",
|
||||
systemSent: true,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: "auto",
|
||||
@@ -339,49 +163,50 @@ private actor LocalFixtureChatStore {
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingDefault: "auto")
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: self.fixture.modelProvider,
|
||||
model: self.fixture.modelID,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: Self.thinkingLevels,
|
||||
thinkingOptions: Self.thinkingOptions,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingDefault: "auto",
|
||||
mainSessionKey: self.fixture.sessionKey),
|
||||
mainSessionKey: self.sessionKey),
|
||||
sessions: [entry])
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.messages = Self.seedMessages(fixture: self.fixture)
|
||||
self.messages = Self.seedMessages()
|
||||
}
|
||||
|
||||
private static var thinkingOptions: [String] {
|
||||
["auto", "low", "medium", "high"]
|
||||
}
|
||||
|
||||
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
|
||||
[
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
|
||||
private static func seedMessages() -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return fixture.seedMessages.enumerated().map { index, text in
|
||||
self.message(role: "assistant", text: text, timestamp: now + Double(index))
|
||||
}
|
||||
return [
|
||||
self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
timestamp: now),
|
||||
]
|
||||
}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
|
||||
@@ -398,9 +223,9 @@ private actor LocalFixtureChatStore {
|
||||
timestamp: timestamp)
|
||||
}
|
||||
|
||||
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
|
||||
private static func normalizedSessionKey(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
|
||||
|
||||
@@ -5,7 +5,7 @@ import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
|
||||
static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
|
||||
@@ -202,4 +202,10 @@ final class ContactsService: ContactsServicing {
|
||||
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
|
||||
@@ -303,7 +303,7 @@ extension AgentProTab {
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
@@ -557,7 +557,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!self.appModel.isLocalGatewayFixtureEnabled &&
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
self.gatewayConnected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
@@ -125,11 +127,13 @@ struct AgentProTab: View {
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
@@ -180,6 +184,9 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
@@ -188,4 +195,11 @@ struct AgentProTab: View {
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelTransportModeID = ""
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
@@ -64,7 +64,6 @@ struct ChatProTab: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.safeAreaPadding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.navigationBarHidden(true)
|
||||
@@ -79,10 +78,6 @@ struct ChatProTab: View {
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
@@ -108,6 +103,7 @@ struct ChatProTab: View {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@@ -139,12 +135,14 @@ struct ChatProTab: View {
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let transportModeID = self.appModel.chatTransportModeID
|
||||
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
guard let viewModel else {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -153,11 +151,13 @@ struct ChatProTab: View {
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelTransportModeID != transportModeID {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -226,7 +226,7 @@ struct ChatProTab: View {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
|
||||
@@ -185,3 +185,33 @@ struct CommandEmptyStateRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandTaskRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Text(self.item.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
|
||||
Text(self.item.detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(width: 64, alignment: .leading)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 56)
|
||||
}
|
||||
Text(self.item.state)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,11 +370,12 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
self.appModel.chatTransportModeID
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
@@ -413,7 +414,9 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
@@ -762,7 +765,9 @@ struct CommandSessionsScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
@@ -774,10 +779,11 @@ struct CommandSessionsScreen: View {
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
self.chatTransportModeID
|
||||
if self.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,11 +180,12 @@ struct IPadActivityScreen: View {
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
self.appModel.chatTransportModeID
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
@@ -214,7 +215,9 @@ struct IPadActivityScreen: View {
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
|
||||
@@ -213,6 +213,32 @@ struct IPadSkillWorkshopScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Status")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
ForEach(Self.proposalStatusFilters, id: \.self) { filter in
|
||||
Button(Self.proposalStatusFilterLabel(filter)) {
|
||||
self.statusFilter = filter
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.statusFilterLabel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(self.neutralControlTint)
|
||||
}
|
||||
}
|
||||
|
||||
private var agentScopeMenu: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Agent")
|
||||
@@ -1104,6 +1130,7 @@ struct IPadSkillProposalRecord: Decodable {
|
||||
let description: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let proposedVersion: String
|
||||
let target: IPadSkillProposalTarget
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
|
||||
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
|
||||
static let lightCanvasBottom = Color.white
|
||||
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
|
||||
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
|
||||
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
|
||||
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
@@ -74,6 +81,11 @@ enum OpenClawBrand {
|
||||
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
|
||||
: UIColor.white
|
||||
})
|
||||
static let graphiteSoft = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
|
||||
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
|
||||
})
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
@@ -85,6 +97,40 @@ enum OpenClawBrand {
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static var toolbarChrome: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphiteElevated.opacity(0.92),
|
||||
graphite.opacity(0.78),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static func glassFill(brighten: Bool) -> Color {
|
||||
Color.black.opacity(brighten ? 0.10 : 0.22)
|
||||
}
|
||||
|
||||
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
|
||||
if active {
|
||||
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
|
||||
}
|
||||
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
|
||||
}
|
||||
|
||||
static func formSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
|
||||
colorScheme == .dark
|
||||
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
|
||||
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -5,6 +5,7 @@ enum OpenClawProMetric {
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@@ -249,6 +250,13 @@ struct OpenClawSidebarRevealButton: View {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
@@ -422,6 +430,46 @@ struct ProProgressBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProWorkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
var progress: Double?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Text(self.trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if let progress {
|
||||
ProProgressBar(progress: progress, color: self.color)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Text(self.state)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCapsule: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -505,6 +553,94 @@ struct OpenClawGatewayCompactPill: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
|
||||
Button {
|
||||
self.selection = index
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.trackFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func segmentFill(isSelected: Bool) -> Color {
|
||||
guard isSelected else { return .clear }
|
||||
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
|
||||
}
|
||||
|
||||
private var trackStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProHeroActionButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
|
||||
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
|
||||
radius: 18,
|
||||
isProminent: true,
|
||||
interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
@@ -659,3 +795,24 @@ struct ProStatusRow: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProTimelineRow: View {
|
||||
let done: Bool
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(
|
||||
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
|
||||
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
@@ -332,6 +332,65 @@ struct SettingsChannelsDestination: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
|
||||
@@ -139,6 +139,15 @@ extension SettingsProTab {
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
func refreshGateway() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
@@ -191,7 +200,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
@@ -289,7 +298,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port) else { return }
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@@ -318,7 +327,7 @@ extension SettingsProTab {
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String, port: Int) async -> Bool {
|
||||
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
|
||||
@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "ai.openclawfoundation.app.network-status")
|
||||
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
|
||||
@@ -3,6 +3,7 @@ import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import EventKit
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -125,7 +126,6 @@ final class GatewayConnectionController {
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
@@ -138,7 +138,6 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
@@ -148,7 +147,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if self.discoveryEnabled {
|
||||
if startDiscovery {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -158,11 +157,6 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
self.discovery.stop()
|
||||
@@ -175,13 +169,12 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
func allowAutoConnectAgain() {
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
@@ -529,7 +522,8 @@ final class GatewayConnectionController {
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS)
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
@@ -725,7 +719,8 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
@@ -752,7 +747,8 @@ final class GatewayConnectionController {
|
||||
|
||||
private func resolveManualTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool) -> GatewayTLSParams?
|
||||
tlsEnabled: Bool,
|
||||
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||
{
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
@@ -789,6 +785,126 @@ final class GatewayConnectionController {
|
||||
resolver.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
(host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBonjourServiceToHostPort(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
|
||||
{
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
// resume the continuation exactly once (timeout/cancel safe).
|
||||
@MainActor
|
||||
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||
private let service: NetService
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
private var finished = false
|
||||
|
||||
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||
self.cont = cont
|
||||
self.service = service
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(timeoutSeconds: TimeInterval) {
|
||||
self.service.delegate = self
|
||||
self.service.schedule(in: .main, forMode: .default)
|
||||
|
||||
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||
// callbacks never arrive (e.g. local network permission issues).
|
||||
self.timeoutTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
self.service.resolve(withTimeout: timeoutSeconds)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
self.finish(Self.extractHostPort(sender))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
private func finish(_ result: (host: String, port: Int)?) {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .default)
|
||||
|
||||
let c = self.cont
|
||||
self.cont = nil
|
||||
c?.resume(returning: result)
|
||||
}
|
||||
|
||||
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||
let port = svc.port
|
||||
|
||||
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
let rc = getnameinfo(
|
||||
base.assumingMemoryBound(to: sockaddr.self),
|
||||
socklen_t(ptr.count),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
let service = NetService(domain: domain, type: type, name: name)
|
||||
let resolver = Resolver(cont: cont, service: service)
|
||||
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
@@ -1046,10 +1162,30 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() async -> [String: Bool] {
|
||||
await self.currentPermissions()
|
||||
}
|
||||
|
||||
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
DeviceInfoHelper.platformString()
|
||||
}
|
||||
|
||||
func _test_deviceFamily() -> String {
|
||||
DeviceInfoHelper.deviceFamily()
|
||||
}
|
||||
|
||||
func _test_modelIdentifier() -> String {
|
||||
DeviceInfoHelper.modelIdentifier()
|
||||
}
|
||||
|
||||
func _test_appVersion() -> String {
|
||||
DeviceInfoHelper.appVersion()
|
||||
}
|
||||
|
||||
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
self.gateways = gateways
|
||||
}
|
||||
@@ -1063,9 +1199,10 @@ extension GatewayConnectionController {
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
|
||||
}
|
||||
|
||||
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
|
||||
|
||||
@@ -59,7 +59,7 @@ final class GatewayDiscoveryModel {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
|
||||
@@ -2,13 +2,18 @@ import Foundation
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private static let nodeService = "ai.openclawfoundation.app.node"
|
||||
private static let talkService = "ai.openclawfoundation.app.talk"
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
|
||||
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
|
||||
private static let manualHostDefaultsKey = "gateway.manual.host"
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
@@ -179,6 +184,24 @@ enum GatewaySettingsStore {
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
stableID
|
||||
case let .discovered(stableID, _):
|
||||
stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String, Codable {
|
||||
@@ -206,6 +229,17 @@ enum GatewaySettingsStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
|
||||
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
|
||||
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
||||
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(service: self.talkService, account: account)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
|
||||
@@ -443,8 +477,8 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
|
||||
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
@@ -546,4 +580,11 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
self.queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID).bgrefresh</string>
|
||||
<string>ai.openclaw.ios.bgrefresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.openclawfoundation.app</string>
|
||||
<string>ai.openclaw.ios</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>openclaw</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "LiveActivity")
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private let connectingStaleSeconds: TimeInterval = 120
|
||||
private let hydrationStaleSeconds: TimeInterval = 300
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
@@ -17,6 +17,15 @@ final class LiveActivityManager {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
guard let activity = self.currentActivity else { return false }
|
||||
guard activity.activityState == .active else {
|
||||
self.currentActivity = nil
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
@@ -87,6 +96,10 @@ final class LiveActivityManager {
|
||||
self.endActivity(reason: "connected")
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.endActivity(reason: "disconnected")
|
||||
}
|
||||
|
||||
func endActivity(reason: String) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
self.currentActivity = nil
|
||||
@@ -170,6 +183,15 @@ final class LiveActivityManager {
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
|
||||
@@ -14,3 +14,39 @@ struct OpenClawActivityAttributes: ActivityAttributes {
|
||||
var startedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension OpenClawActivityAttributes {
|
||||
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
|
||||
}
|
||||
|
||||
extension OpenClawActivityAttributes.ContentState {
|
||||
static let connecting = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: .now)
|
||||
|
||||
static let idle = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let disconnected = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let attention = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Approval needed",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,8 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
|
||||
private var isStreaming = false
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
@@ -82,6 +84,42 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
self.isStreaming = true
|
||||
if significantChangesOnly {
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
} else {
|
||||
self.manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
|
||||
self.updatesContinuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { @MainActor in
|
||||
self.stopLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLocationUpdates() {
|
||||
guard self.isStreaming else { return }
|
||||
self.isStreaming = false
|
||||
self.manager.stopUpdatingLocation()
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
self.updatesContinuation?.finish()
|
||||
self.updatesContinuation = nil
|
||||
}
|
||||
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
|
||||
self.significantLocationCallback = onUpdate
|
||||
guard !self.isMonitoringSignificantChanges else { return }
|
||||
@@ -89,6 +127,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
func stopMonitoringSignificantLocationChanges() {
|
||||
guard self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = false
|
||||
self.significantLocationCallback = nil
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
@@ -116,6 +161,9 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
if let callback = self.significantLocationCallback, let latest = locs.last {
|
||||
callback(latest)
|
||||
}
|
||||
if let latest = locs.last, let updates = self.updatesContinuation {
|
||||
updates.yield(latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,14 +88,14 @@ final class NodeAppModel {
|
||||
var pendingApprovalIDs: [String]?
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclawfoundation.app",
|
||||
subsystem: "ai.openclaw.ios",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
@@ -112,7 +112,6 @@ final class NodeAppModel {
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
||||
private(set) var isScreenshotFixtureModeEnabled: Bool = false
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
@@ -134,6 +133,7 @@ final class NodeAppModel {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
private var focusedChatSessionKey: String?
|
||||
var selectedAgentId: String?
|
||||
@@ -202,41 +202,14 @@ final class NodeAppModel {
|
||||
private var apnsDeviceTokenHex: String?
|
||||
private var apnsLastRegisteredTokenHex: String?
|
||||
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||
var gatewaySession: GatewayNodeSession {
|
||||
self.nodeGateway
|
||||
}
|
||||
|
||||
var operatorSession: GatewayNodeSession {
|
||||
self.operatorGateway
|
||||
}
|
||||
|
||||
var localChatFixture: LocalChatFixture? {
|
||||
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
|
||||
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isLocalChatFixtureEnabled: Bool {
|
||||
self.localChatFixture != nil
|
||||
}
|
||||
|
||||
var isLocalGatewayFixtureEnabled: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
|
||||
}
|
||||
|
||||
var chatTransportModeID: String {
|
||||
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
|
||||
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
func makeChatTransport() -> any OpenClawChatTransport {
|
||||
if self.isScreenshotFixtureModeEnabled {
|
||||
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
||||
}
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
return AppleReviewDemoChatTransport()
|
||||
}
|
||||
return IOSGatewayChatTransport(gateway: self.operatorSession)
|
||||
}
|
||||
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
|
||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
||||
@@ -578,7 +551,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -751,6 +724,11 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
|
||||
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||
@@ -760,9 +738,12 @@ final class NodeAppModel {
|
||||
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let session = config["session"] as? [String: Any]
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
@@ -945,7 +926,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -1967,7 +1948,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
self.activeGatewayConnectConfig = nextConfig
|
||||
self.prepareForGatewayConnect(stableID: effectiveStableID)
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
if operatorLoopRequired {
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
@@ -1994,7 +1975,6 @@ extension NodeAppModel {
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -2029,7 +2009,7 @@ extension NodeAppModel {
|
||||
|
||||
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
|
||||
await self.resetGatewaySessionsForForcedReconnect()
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -2040,7 +2020,6 @@ extension NodeAppModel {
|
||||
|
||||
func disconnectGateway() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2066,6 +2045,7 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
ShareGatewayRelaySettings.clearConfig()
|
||||
@@ -2074,9 +2054,8 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(stableID: String) {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2119,7 +2098,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -2145,7 +2124,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.operatorGatewayProblem = problem
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
@@ -2378,7 +2357,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
self.setOperatorConnected(true)
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
@@ -2400,7 +2379,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
@@ -2425,7 +2404,7 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
||||
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
||||
@@ -2491,7 +2470,7 @@ extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
@@ -2519,7 +2498,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return false }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
@@ -2578,7 +2557,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
@@ -2628,7 +2607,7 @@ extension NodeAppModel {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
@@ -2669,7 +2648,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isLocalGatewayFixtureEnabled else { return }
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
@@ -2679,6 +2658,7 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
@@ -2819,7 +2799,6 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
@@ -2852,6 +2831,7 @@ extension NodeAppModel {
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
@@ -2860,47 +2840,6 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
||||
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
||||
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.talkMode.enterScreenshotFixtureMode()
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -3007,6 +2946,14 @@ extension NodeAppModel {
|
||||
self.refreshLastShareEventFromRelay()
|
||||
}
|
||||
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
@@ -3967,6 +3914,32 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func handleExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String) async
|
||||
{
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty else { return }
|
||||
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: normalizedApprovalID,
|
||||
decision: decision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String,
|
||||
@@ -4503,6 +4476,17 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
func approvePendingAgentDeepLinkPrompt() async {
|
||||
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
|
||||
self.pendingAgentDeepLinkPrompt = nil
|
||||
@@ -4652,10 +4636,30 @@ extension NodeAppModel {
|
||||
try self.encodePayload(obj)
|
||||
}
|
||||
|
||||
func _test_isCameraEnabled() -> Bool {
|
||||
self.isCameraEnabled()
|
||||
}
|
||||
|
||||
func _test_triggerCameraFlash() {
|
||||
self.triggerCameraFlash()
|
||||
}
|
||||
|
||||
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
||||
}
|
||||
|
||||
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
||||
await self.handleCanvasA2UIAction(body: body)
|
||||
}
|
||||
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
|
||||
365
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
365
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
@@ -0,0 +1,365 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Connect to your gateway to get started.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Auto detect") {
|
||||
AutoDetectStep()
|
||||
}
|
||||
NavigationLink("Manual entry") {
|
||||
ManualEntryStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoDetectStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text("We’ll scan for gateways on your network and connect automatically when we find one.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auto detect")
|
||||
.onAppear { self.triggerAutoConnect() }
|
||||
.onChange(of: self.gatewayController.gateways) { _, _ in
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAutoConnect() {
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
guard let candidate = self.autoCandidate() else { return }
|
||||
|
||||
self.connectingGatewayID = candidate.id
|
||||
Task {
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !preferred.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if !lastDiscovered.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if self.gatewayController.gateways.count == 1 {
|
||||
return self.gatewayController.gateways.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPortText: String = ""
|
||||
@State private var manualUseTLS: Bool = true
|
||||
@State private var manualToken: String = ""
|
||||
@State private var manualPassword: String = ""
|
||||
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Setup code") {
|
||||
Text("Use /pair in your bot to get a setup code.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button("Apply setup code") {
|
||||
self.applySetupCode()
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let setupStatusText, !setupStatusText.isEmpty {
|
||||
Text(setupStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualUseTLS)
|
||||
|
||||
TextField("Gateway token", text: self.$manualToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway password", text: self.$manualPassword)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Manual entry")
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
|
||||
if let port = self.manualPortValue(), !(1...65535).contains(port) {
|
||||
self.connectStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.manual.enabled")
|
||||
defaults.set(host, forKey: "gateway.manual.host")
|
||||
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
|
||||
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
|
||||
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !instanceId.isEmpty {
|
||||
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedToken.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "manual"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.manualToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.manualPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualPortValue() ?? 0,
|
||||
useTLS: self.manualUseTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.manualHost = ""
|
||||
self.manualPortText = ""
|
||||
self.manualUseTLS = true
|
||||
self.manualToken = ""
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return
|
||||
}
|
||||
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return
|
||||
}
|
||||
|
||||
self.manualHost = link.host
|
||||
self.manualPortText = String(link.port)
|
||||
self.manualUseTLS = link.tls
|
||||
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.manualToken = setupAuth.token
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.manualPassword = setupAuth.password
|
||||
}
|
||||
|
||||
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(
|
||||
appModel: self.appModel,
|
||||
instanceId: trimmedInstanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
|
||||
self.setupStatusText = "Setup code applied."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
connectingGatewayID: inout String?)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
connectStatusText = nil
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
secondaryLine: String?) -> some View
|
||||
{
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: gatewayConnectionStatusLines(
|
||||
appModel: appModel,
|
||||
gatewayController: gatewayController),
|
||||
secondaryLine: secondaryLine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.statusLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let secondaryLine, !secondaryLine.isEmpty {
|
||||
Text(secondaryLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")")
|
||||
return lines
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,10 @@ enum OnboardingStateStore {
|
||||
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)
|
||||
|
||||
@@ -17,6 +17,10 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
Self(rawValue: self.rawValue - 1)
|
||||
}
|
||||
|
||||
var next: Self? {
|
||||
Self(rawValue: self.rawValue + 1)
|
||||
}
|
||||
|
||||
/// Progress label for the manual setup flow (mode → connect → auth → success).
|
||||
var manualProgressTitle: String {
|
||||
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
@@ -22,22 +22,9 @@ enum OpenClawAppModelRegistry {
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "BackgroundWake")
|
||||
private static var wakeRefreshTaskIdentifier: String {
|
||||
"\(appBundleIdentifier).bgrefresh"
|
||||
}
|
||||
|
||||
private static var appBundleIdentifier: String {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleId.isEmpty
|
||||
else {
|
||||
return "ai.openclawfoundation.app"
|
||||
}
|
||||
|
||||
return bundleId
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake")
|
||||
private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh"
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
@@ -105,10 +92,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
func _test_resolvedAppModel() -> NodeAppModel? {
|
||||
self.resolvedAppModel()
|
||||
}
|
||||
|
||||
func _test_wakeRefreshTaskIdentifier() -> String {
|
||||
Self.wakeRefreshTaskIdentifier
|
||||
}
|
||||
#endif
|
||||
|
||||
func application(
|
||||
@@ -628,21 +611,9 @@ struct OpenClawApp: App {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
#if DEBUG
|
||||
if Self.screenshotModeEnabled {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
UserDefaults.standard.set(true, forKey: "gateway.onboardingComplete")
|
||||
UserDefaults.standard.set(true, forKey: "gateway.hasConnectedOnce")
|
||||
UserDefaults.standard.set(true, forKey: "onboarding.quickSetupDismissed")
|
||||
appModel.enterScreenshotFixtureMode()
|
||||
}
|
||||
#endif
|
||||
OpenClawAppModelRegistry.appModel = appModel
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -678,14 +649,6 @@ struct OpenClawApp: App {
|
||||
?? .system
|
||||
}
|
||||
|
||||
private static var screenshotModeEnabled: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("--openclaw-screenshot-mode")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
|
||||
@@ -39,6 +39,10 @@ struct PushBuildConfig {
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
var usesRelay: Bool {
|
||||
self.transport == .relay
|
||||
}
|
||||
|
||||
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -13,7 +13,7 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
private static let service = "ai.openclawfoundation.app.pushrelay"
|
||||
private static let service = "ai.openclaw.pushrelay"
|
||||
private static let registrationStateAccount = "registration-state"
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
@@ -71,6 +71,11 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearRegistrationState() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -7,6 +7,7 @@ extension RootTabs {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
|
||||
15
apps/ios/Sources/RootView.swift
Normal file
15
apps/ios/Sources/RootView.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,16 @@ final class ScreenController {
|
||||
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
func snapshotBase64(
|
||||
maxWidth: CGFloat? = nil,
|
||||
format: OpenClawCanvasSnapshotFormat,
|
||||
|
||||
@@ -56,7 +56,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "ai.openclawfoundation.app.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -31,7 +31,12 @@ protocol LocationServicing: Sendable {
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
func stopLocationUpdates()
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
|
||||
func stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -26,7 +26,7 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
|
||||
}
|
||||
|
||||
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "watch.messaging")
|
||||
private nonisolated static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
|
||||
private let session: WCSession?
|
||||
private let callbacksLock = NSLock()
|
||||
|
||||
@@ -22,4 +22,11 @@ enum SessionKey {
|
||||
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return agentId.isEmpty ? nil : agentId
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
if trimmed == "global" { return true }
|
||||
return trimmed.hasPrefix("agent:")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,18 +121,13 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.contactsStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
let granted = await PermissionRequestBridge.awaitRequest { completion in
|
||||
_ = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.contactsStatus = .authorized
|
||||
}
|
||||
}
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -169,13 +164,8 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
let granted = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .writeOnly
|
||||
}
|
||||
}
|
||||
_ = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -216,13 +206,8 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
let granted = await self.requestCalendarFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.calendarStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
_ = await self.requestCalendarFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
@@ -263,13 +248,8 @@ struct PrivacyAccessSectionView: View {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
let granted = await self.requestRemindersFull()
|
||||
await MainActor.run {
|
||||
self.refreshAll()
|
||||
if granted {
|
||||
self.remindersStatus = .fullAccess
|
||||
}
|
||||
}
|
||||
_ = await self.requestRemindersFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
|
||||
40
apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift
Normal file
40
apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct SettingsHostPort: Equatable {
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
|
||||
enum SettingsNetworkingHelpers {
|
||||
static func parseHostPort(from address: String) -> SettingsHostPort? {
|
||||
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("["),
|
||||
let close = trimmed.firstIndex(of: "]"),
|
||||
close < trimmed.endIndex
|
||||
{
|
||||
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
|
||||
let portStart = trimmed.index(after: close)
|
||||
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
|
||||
let portString = String(trimmed[trimmed.index(after: portStart)...])
|
||||
guard let port = Int(portString) else { return nil }
|
||||
return SettingsHostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
|
||||
let host = String(trimmed[..<colon])
|
||||
let portString = String(trimmed[trimmed.index(after: colon)...])
|
||||
guard !host.isEmpty, let port = Int(portString) else { return nil }
|
||||
return SettingsHostPort(host: host, port: port)
|
||||
}
|
||||
|
||||
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||
if let host, let port {
|
||||
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
|
||||
let hostPart = needsBrackets ? "[\(host)]" : host
|
||||
return "http://\(hostPart):\(port)"
|
||||
}
|
||||
return "http://\(fallback)"
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ final class RealtimeTalkRelaySession {
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
private let pcmPlayer: PCMStreamingAudioPlaying
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "RealtimeTalkRelay")
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
|
||||
private let onStatus: (String) -> Void
|
||||
private let onIssue: (TalkRuntimeIssue) -> Void
|
||||
private let onSpeakingChanged: (Bool) -> Void
|
||||
|
||||
@@ -3,6 +3,7 @@ import OpenClawKit
|
||||
|
||||
enum TalkModeExecutionMode {
|
||||
case native
|
||||
case realtimeClient
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,22 @@ extension TalkModeManager {
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
|
||||
@@ -70,6 +70,11 @@ final class TalkModeManager: NSObject {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
var hasActiveAudioCapture: Bool {
|
||||
self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil
|
||||
|| self.realtimeRelayStartInFlight
|
||||
}
|
||||
|
||||
private enum CaptureMode {
|
||||
case idle
|
||||
case continuous
|
||||
@@ -170,7 +175,7 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
|
||||
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkMode")
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode")
|
||||
|
||||
private static func nowSeconds() -> TimeInterval {
|
||||
ProcessInfo.processInfo.systemUptime
|
||||
@@ -220,34 +225,6 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.updateGatewayConnected(true)
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
self.statusText = "Ready"
|
||||
self.gatewayTalkConfigLoaded = true
|
||||
self.gatewayTalkApiKeyConfigured = true
|
||||
self.gatewayTalkDefaultModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkDefaultVoiceId = "marin"
|
||||
self.gatewayTalkProviderLabel = "OpenAI"
|
||||
self.gatewayTalkTransportLabel = "Gateway Relay"
|
||||
self.gatewayTalkUsesRealtime = true
|
||||
self.gatewayTalkUsesRealtimeRelay = true
|
||||
self.gatewayTalkRealtimeProviderLabel = "OpenAI"
|
||||
self.gatewayTalkRealtimeModelId = "gpt-realtime-2"
|
||||
self.gatewayTalkRealtimeVoiceId = "marin"
|
||||
self.gatewayTalkVoiceModeTitle = "Realtime Voice"
|
||||
self.gatewayTalkVoiceModeSubtitle = "Gateway relay ready"
|
||||
self.gatewayTalkVoiceModeAccessibilityValue = "Realtime Voice, Gateway relay ready"
|
||||
self.gatewayTalkActiveModeTitle = "Ready"
|
||||
self.gatewayTalkActiveModeSubtitle = "Listening starts from this phone"
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkPermissionState = .ready
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.isEnabled = enabled
|
||||
if enabled {
|
||||
@@ -498,6 +475,13 @@ final class TalkModeManager: NSObject {
|
||||
return wasActive
|
||||
}
|
||||
|
||||
func setForegroundAudioCaptureAllowed(_ allowed: Bool) {
|
||||
self.foregroundAudioCaptureAllowed = allowed
|
||||
if !allowed {
|
||||
self.cancelPendingStart()
|
||||
}
|
||||
}
|
||||
|
||||
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
|
||||
if wasKeptActive { return }
|
||||
guard wasSuspended else { return }
|
||||
@@ -505,6 +489,14 @@ final class TalkModeManager: NSObject {
|
||||
await self.start()
|
||||
}
|
||||
|
||||
func userTappedOrb() {
|
||||
if let realtimeSession {
|
||||
realtimeSession.cancelResponse()
|
||||
}
|
||||
self.realtimeRelaySession?.cancelOutput()
|
||||
self.stopSpeaking()
|
||||
}
|
||||
|
||||
func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload {
|
||||
guard self.gatewayConnected else {
|
||||
self.statusText = "Offline"
|
||||
@@ -3112,6 +3104,23 @@ extension TalkModeManager {
|
||||
self.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
func _test_seedTranscript(_ transcript: String) {
|
||||
self.lastTranscript = transcript
|
||||
self.lastHeard = Date()
|
||||
}
|
||||
|
||||
func _test_handleTranscript(_ transcript: String, isFinal: Bool) async {
|
||||
await self.handleTranscript(transcript: transcript, isFinal: isFinal)
|
||||
}
|
||||
|
||||
func _test_backdateLastHeard(seconds: TimeInterval) {
|
||||
self.lastHeard = Date().addingTimeInterval(-seconds)
|
||||
}
|
||||
|
||||
func _test_runSilenceCheck() async {
|
||||
await self.checkSilence()
|
||||
}
|
||||
|
||||
func _test_incrementalReset() {
|
||||
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct TalkPermissionPromptView: View {
|
||||
enum Style {
|
||||
case card
|
||||
case settings
|
||||
case sheet
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
|
||||
|
||||
@MainActor
|
||||
final class TalkRealtimeWebRTCSession: NSObject {
|
||||
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "TalkRealtimeWebRTC")
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "TalkRealtimeWebRTC")
|
||||
private static let consultToolName = "openclaw_agent_consult"
|
||||
private static let controlToolName = "openclaw_agent_control"
|
||||
private static let defaultOfferURL = "https://api.openai.com/v1/realtime/calls"
|
||||
@@ -61,6 +61,7 @@ final class TalkRealtimeWebRTCSession: NSObject {
|
||||
let runId: String?
|
||||
let status: String?
|
||||
let startedAt: Double?
|
||||
let endedAt: Double?
|
||||
let error: String?
|
||||
let stopReason: String?
|
||||
let timeoutPhase: String?
|
||||
@@ -195,6 +196,11 @@ final class TalkRealtimeWebRTCSession: NSObject {
|
||||
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func cancelResponse() {
|
||||
self.sendRealtimeEvent(["type": "response.cancel"])
|
||||
self.cancelActiveToolCalls()
|
||||
}
|
||||
|
||||
private func cancelActiveToolCalls() {
|
||||
let runIds = Array(Set(activeToolRunIds.values))
|
||||
for task in self.activeToolTasks.values {
|
||||
|
||||
@@ -70,6 +70,14 @@ enum TalkSpeechLocale {
|
||||
return (recognizer, recognizer?.locale.identifier)
|
||||
}
|
||||
|
||||
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
|
||||
}
|
||||
|
||||
private static func normalizedLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedSpeechLocaleID(raw)
|
||||
}
|
||||
|
||||
private static func canonicalID(_ raw: String) -> String {
|
||||
raw.replacingOccurrences(of: "_", with: "-")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ Sources/Design/ChatProTab.swift
|
||||
Sources/Design/CommandCenterTab.swift
|
||||
Sources/Design/TalkProTab.swift
|
||||
Sources/Design/OpenClawProComponents.swift
|
||||
Sources/Design/OpenClawProScreens.swift
|
||||
Sources/Design/SettingsProTab.swift
|
||||
Sources/Design/SettingsProTabSupport.swift
|
||||
Sources/Design/SettingsProTabSections.swift
|
||||
@@ -66,6 +67,7 @@ Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/WatchReplyCoordinator.swift
|
||||
Sources/Motion/MotionService.swift
|
||||
Sources/Onboarding/GatewayOnboardingReset.swift
|
||||
Sources/Onboarding/GatewayOnboardingView.swift
|
||||
Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardSteps.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
@@ -81,6 +83,7 @@ Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootTabsNavigation.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
@@ -91,6 +94,7 @@ Sources/Services/WatchMessagingPayloadCodec.swift
|
||||
Sources/Services/WatchMessagingService.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
|
||||
@@ -371,18 +371,15 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
@@ -398,18 +395,15 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(
|
||||
prior,
|
||||
service: "ai.openclawfoundation.app.gateway",
|
||||
account: "lastConnection")
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclawfoundation.app.gateway", account: "lastConnection")
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
|
||||
withUserDefaults([
|
||||
|
||||
@@ -39,19 +39,19 @@ import Testing
|
||||
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
fingerprint: "22")
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
@@ -59,17 +59,17 @@ import Testing
|
||||
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
fingerprint: "22")
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ import Testing
|
||||
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
self.clearTLSFingerprint(stableID: stableID)
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.autoconnect")
|
||||
@@ -90,13 +90,13 @@ import Testing
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let gateway = self.makeDiscoveredGateway(
|
||||
let gateway = makeDiscoveredGateway(
|
||||
stableID: stableID,
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
fingerprint: nil)
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
@@ -104,7 +104,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
|
||||
@@ -120,7 +120,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
|
||||
@@ -131,7 +131,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
|
||||
let controller = self.makeController()
|
||||
let controller = makeController()
|
||||
|
||||
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
|
||||
|
||||
@@ -7,11 +7,13 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let gatewayService = "ai.openclawfoundation.app.gateway"
|
||||
private let nodeService = "ai.openclawfoundation.app.node"
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let talkService = "ai.openclaw.talk"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
|
||||
private let bootstrapDefaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
@@ -185,4 +187,17 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_genericRoundTrip() {
|
||||
let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry])
|
||||
defer { restoreKeychain(keychainSnapshot) }
|
||||
|
||||
_ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account)
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key")
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
|
||||
@Suite struct KeychainStoreTests {
|
||||
@Test func saveLoadUpdateDeleteRoundTrip() {
|
||||
let service = "ai.openclawfoundation.app.tests.\(UUID().uuidString)"
|
||||
let service = "ai.openclaw.tests.\(UUID().uuidString)"
|
||||
let account = "value"
|
||||
|
||||
#expect(KeychainStore.delete(service: service, account: account))
|
||||
|
||||
@@ -56,6 +56,12 @@ import Testing
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
|
||||
OnboardingStateStore.markIncomplete(defaults: defaults)
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
}
|
||||
|
||||
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OpenClawAppDelegateTests {
|
||||
@Test @MainActor func `resolves registry model before view task assigns delegate model`() {
|
||||
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
|
||||
let registryModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
defer { OpenClawAppModelRegistry.appModel = nil }
|
||||
@@ -13,7 +12,7 @@ import Testing
|
||||
#expect(delegate._test_resolvedAppModel() === registryModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func `prefers explicit delegate model over registry fallback`() {
|
||||
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
|
||||
let registryModel = NodeAppModel()
|
||||
let explicitModel = NodeAppModel()
|
||||
OpenClawAppModelRegistry.appModel = registryModel
|
||||
@@ -24,11 +23,4 @@ import Testing
|
||||
|
||||
#expect(delegate._test_resolvedAppModel() === explicitModel)
|
||||
}
|
||||
|
||||
@Test @MainActor func `derives background refresh task identifier from app bundle identifier`() {
|
||||
let delegate = OpenClawAppDelegate()
|
||||
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "ai.openclawfoundation.app.tests"
|
||||
|
||||
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +153,13 @@ import Testing
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
let directDestination = try Self.extract(
|
||||
source,
|
||||
from: "private func directDestination(for route: AgentRoute) -> some View",
|
||||
to: "private func applyInitialRouteIfNeeded()")
|
||||
|
||||
#expect(!source.contains("ToolbarItem"))
|
||||
#expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(!directDestination.contains("ToolbarItem"))
|
||||
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
@@ -494,6 +498,7 @@ import Testing
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
@@ -517,7 +522,9 @@ import Testing
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
|
||||
@@ -29,4 +29,50 @@ import Testing
|
||||
talkConfigLoaded: true,
|
||||
notificationStatusText: "Allowed") == 0)
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesIPv4() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesHostnameAndTrims() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
|
||||
host: "example.com",
|
||||
port: 80))
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesBracketedIPv6() {
|
||||
#expect(
|
||||
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
|
||||
.init(host: "2001:db8::1", port: 443))
|
||||
}
|
||||
|
||||
@Test func parseHostPortRejectsMissingPort() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
|
||||
}
|
||||
|
||||
@Test func parseHostPortRejectsInvalidPort() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
|
||||
}
|
||||
|
||||
@Test func httpURLStringFormatsIPv4AndPort() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringBracketsIPv6() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
|
||||
#expect(SettingsNetworkingHelpers
|
||||
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
|
||||
}
|
||||
|
||||
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
|
||||
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
|
||||
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ import UIKit
|
||||
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(OpenClawDocsScreen()),
|
||||
AnyView(SettingsChannelsScreen()),
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
AnyView(AgentProTab(directRoute: .agents)),
|
||||
|
||||
@@ -1,24 +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>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClawUITests</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,58 +0,0 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class OpenClawSnapshotUITests: XCTestCase {
|
||||
private struct ScreenshotTarget {
|
||||
let initialTab: String
|
||||
let initialDestination: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
private static let screenshotTargets = [
|
||||
ScreenshotTarget(initialTab: "control", initialDestination: "overview", name: "01-control-connected"),
|
||||
ScreenshotTarget(initialTab: "chat", initialDestination: "chat", name: "02-chat-connected"),
|
||||
ScreenshotTarget(initialTab: "talk", initialDestination: "talk", name: "03-talk-connected"),
|
||||
ScreenshotTarget(initialTab: "agent", initialDestination: "agents", name: "04-agent-connected"),
|
||||
ScreenshotTarget(initialTab: "settings", initialDestination: "settings", name: "05-settings-connected"),
|
||||
]
|
||||
|
||||
private var app: XCUIApplication?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
self.continueAfterFailure = false
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
self.app?.terminate()
|
||||
self.app = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testConnectedGatewayTabs() {
|
||||
for target in Self.screenshotTargets {
|
||||
self.launchApp(for: target)
|
||||
snapshot(target.name, timeWaitingForIdle: 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func launchApp(for target: ScreenshotTarget) {
|
||||
self.app?.terminate()
|
||||
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app, waitForAnimations: false)
|
||||
app.launchArguments += [
|
||||
"--openclaw-screenshot-mode",
|
||||
"--openclaw-initial-tab",
|
||||
target.initialTab,
|
||||
"--openclaw-initial-destination",
|
||||
target.initialDestination,
|
||||
"--openclaw-sidebar-visibility",
|
||||
"hidden",
|
||||
]
|
||||
app.launch()
|
||||
self.app = app
|
||||
|
||||
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
var deviceLanguage = ""
|
||||
var locale = ""
|
||||
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if locale.isEmpty && !deviceLanguage.isEmpty {
|
||||
locale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !locale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.27]
|
||||
@@ -64,7 +64,7 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
|
||||
- generated from `apps/ios/CHANGELOG.md`
|
||||
- `apps/ios/build/Version.xcconfig`
|
||||
- local gitignored build override generated per build or release prep
|
||||
- local gitignored build override generated per build or beta prep
|
||||
|
||||
## Tooling surfaces
|
||||
|
||||
@@ -81,21 +81,16 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- `scripts/ios-pin-version.ts`
|
||||
- explicitly pins iOS to a chosen release version or the current gateway version
|
||||
|
||||
### Build and App Store release flow
|
||||
### Build and beta flow
|
||||
|
||||
- `scripts/ios-write-version-xcconfig.sh`
|
||||
- reads the pinned iOS version
|
||||
- writes the local numeric build override file in `apps/ios/build/Version.xcconfig`
|
||||
- `scripts/ios-release-prepare.sh`
|
||||
- prepares App Store distribution signing and bundle settings against the pinned iOS version
|
||||
- `scripts/ios-release-signing.mjs`
|
||||
- validates the checked-in App Store signing manifest
|
||||
- creates or verifies Developer Portal bundle IDs, capabilities, certificates, and profiles through `asc`
|
||||
- syncs encrypted signing assets with the private shared signing repo
|
||||
- `scripts/ios-beta-prepare.sh`
|
||||
- prepares beta signing and bundle settings against the pinned iOS version
|
||||
- `apps/ios/fastlane/Fastfile`
|
||||
- resolves version metadata from the pinned iOS helper
|
||||
- increments App Store Connect build numbers for the pinned short version
|
||||
- uploads screenshots and release notes before archiving a release build
|
||||
- increments TestFlight build numbers for the pinned short version
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
@@ -123,7 +118,7 @@ pnpm ios:version:pin -- --version 2026.4.10
|
||||
|
||||
1. keep `apps/ios/version.json` pinned to the current TestFlight train version
|
||||
2. update `apps/ios/CHANGELOG.md` under `## Unreleased` while iterating
|
||||
3. upload more App Store Connect builds with `pnpm ios:release:upload`
|
||||
3. upload more betas with the usual flow
|
||||
4. let Fastlane increment only `CFBundleVersion`
|
||||
|
||||
This keeps the TestFlight version stable while review is in flight.
|
||||
@@ -144,15 +139,12 @@ pnpm ios:version:pin -- --from-gateway
|
||||
- `apps/ios/fastlane/metadata/en-US/release_notes.txt`
|
||||
3. update `apps/ios/CHANGELOG.md` for the new release if needed
|
||||
4. run `pnpm ios:version:sync` again if the changelog changed
|
||||
5. upload the first App Store Connect build for that newly pinned version
|
||||
5. submit the first TestFlight build for that newly pinned version
|
||||
6. keep iterating only by build number until the release candidate is ready
|
||||
7. manually submit the reviewed build for App Review in App Store Connect
|
||||
8. release the approved build to production
|
||||
7. release that reviewed TestFlight build to production
|
||||
|
||||
## Important invariant
|
||||
|
||||
Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/version.json`.
|
||||
|
||||
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
|
||||
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
|
||||
|
||||
@@ -3,17 +3,10 @@ import SwiftUI
|
||||
@main
|
||||
struct OpenClawWatchApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var inboxStore = WatchInboxStore(
|
||||
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
|
||||
@State private var inboxStore = WatchInboxStore()
|
||||
@State private var receiver: WatchConnectivityReceiver?
|
||||
@State private var execApprovalRefreshTask: Task<Void, Never>?
|
||||
|
||||
private static let screenshotModeDefaultsKey = "openclaw.watch.screenshotMode"
|
||||
private static let isScreenshotMode = ProcessInfo.processInfo.arguments.contains(
|
||||
"--openclaw-watch-screenshot-mode")
|
||||
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|
||||
|| UserDefaults.standard.bool(forKey: OpenClawWatchApp.screenshotModeDefaultsKey)
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchInboxView(
|
||||
@@ -44,10 +37,6 @@ struct OpenClawWatchApp: App {
|
||||
self.refreshExecApprovalReview(force: true)
|
||||
})
|
||||
.task {
|
||||
if OpenClawWatchApp.isScreenshotMode {
|
||||
self.inboxStore.configureScreenshotFixture()
|
||||
return
|
||||
}
|
||||
if self.receiver == nil {
|
||||
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
||||
receiver.activate()
|
||||
@@ -89,32 +78,3 @@ struct OpenClawWatchApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension WatchInboxStore {
|
||||
fileprivate func configureScreenshotFixture() {
|
||||
self.consume(
|
||||
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
|
||||
approvals: [],
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
snapshotId: nil),
|
||||
transport: "screenshot")
|
||||
self.consume(
|
||||
message: WatchNotifyMessage(
|
||||
id: "watch-screenshot-quick-reply",
|
||||
title: "Molty request",
|
||||
body: "Molty Gateway checklist ready.",
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
|
||||
promptId: "watch-screenshot-prompt",
|
||||
sessionKey: "watch-screenshot-session",
|
||||
kind: "release-checklist",
|
||||
details: nil,
|
||||
expiresAtMs: nil,
|
||||
risk: "medium",
|
||||
actions: [
|
||||
WatchPromptAction(id: "approve", label: "Approve", style: nil),
|
||||
WatchPromptAction(id: "later", label: "Later", style: "cancel"),
|
||||
]),
|
||||
transport: "screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,17 +170,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
|
||||
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
||||
private var lastDeliveryKey: String?
|
||||
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
requestNotificationAuthorization: Bool = true)
|
||||
{
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.restorePersistedState()
|
||||
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
||||
if requestNotificationAuthorization {
|
||||
Task {
|
||||
await self.ensureNotificationAuthorization()
|
||||
}
|
||||
Task {
|
||||
await self.ensureNotificationAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,3 @@
|
||||
# Deliver toggles (off by default)
|
||||
# DELIVER_METADATA=1
|
||||
# DELIVER_SCREENSHOTS=1
|
||||
|
||||
# Screenshot generation
|
||||
# OPENCLAW_SNAPSHOT_DEVICES=iPhone 16 Pro Max,iPad Pro 13-inch (M4)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app_identifier("ai.openclawfoundation.app")
|
||||
app_identifier("ai.openclaw.client")
|
||||
|
||||
# Auth is expected via App Store Connect API key.
|
||||
# Provide either:
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
require "shellwords"
|
||||
require "open3"
|
||||
require "json"
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
require "tempfile"
|
||||
require "cgi"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
|
||||
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
|
||||
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
||||
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
|
||||
REQUIRED_SCREENSHOT_FAMILIES = {
|
||||
"iPhone" => /iPhone/,
|
||||
"13-inch iPad" => /iPad (Air|Pro) 13-inch/
|
||||
}.freeze
|
||||
BETA_APP_IDENTIFIER = "ai.openclaw.client"
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -46,294 +33,10 @@ def screenshot_upload_requested?
|
||||
ENV["DELIVER_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def release_notes_upload_requested?
|
||||
ENV["DELIVER_RELEASE_NOTES"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def validate_required_screenshots!(paths)
|
||||
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
|
||||
name unless paths.any? { |path| File.basename(path).match?(pattern) }
|
||||
end
|
||||
return if missing_families.empty?
|
||||
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
|
||||
end
|
||||
|
||||
def snapshot_devices
|
||||
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
||||
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
||||
|
||||
raw.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
|
||||
def watch_snapshot_device
|
||||
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
||||
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
||||
end
|
||||
|
||||
def available_simulator_devices
|
||||
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
|
||||
unless status.success?
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to list simulator devices: #{detail}")
|
||||
end
|
||||
|
||||
JSON.parse(stdout).fetch("devices").values.flatten
|
||||
rescue JSON::ParserError => e
|
||||
UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
|
||||
end
|
||||
|
||||
def resolve_simulator_device(name)
|
||||
devices = available_simulator_devices
|
||||
exact = devices.find { |device| device["name"] == name }
|
||||
return exact if exact
|
||||
|
||||
watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
|
||||
fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
|
||||
UI.user_error!("No available Apple Watch simulators found.") unless fallback
|
||||
|
||||
UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
|
||||
fallback
|
||||
end
|
||||
|
||||
def bundle_identifier_for_product(product_path)
|
||||
info_plist_path = File.join(product_path, "Info.plist")
|
||||
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
|
||||
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"/usr/libexec/PlistBuddy",
|
||||
"-c",
|
||||
"Print:CFBundleIdentifier",
|
||||
info_plist_path
|
||||
)
|
||||
unless status.success?
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
|
||||
end
|
||||
|
||||
bundle_identifier = stdout.to_s.strip
|
||||
UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
|
||||
bundle_identifier
|
||||
end
|
||||
|
||||
def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
||||
bundle_identifiers.each do |bundle_identifier|
|
||||
sh(
|
||||
shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"spawn",
|
||||
udid,
|
||||
"defaults",
|
||||
"write",
|
||||
bundle_identifier,
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
||||
"-bool",
|
||||
"YES",
|
||||
])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
|
||||
bundle_identifiers.each do |bundle_identifier|
|
||||
sh(
|
||||
"#{shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"spawn",
|
||||
udid,
|
||||
"defaults",
|
||||
"delete",
|
||||
bundle_identifier,
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
|
||||
])} >/dev/null 2>&1 || true"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def status_bar_unsupported?(detail)
|
||||
detail.include?("Status bar overrides not supported on this platform") ||
|
||||
detail.include?("Operation not supported")
|
||||
end
|
||||
|
||||
def set_watch_status_bar_override(udid)
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"status_bar",
|
||||
udid,
|
||||
"override",
|
||||
"--time",
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME
|
||||
)
|
||||
return true if status.success?
|
||||
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
if status_bar_unsupported?(detail)
|
||||
UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
|
||||
return false
|
||||
end
|
||||
|
||||
UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
|
||||
end
|
||||
|
||||
def clear_watch_status_bar_override(udid)
|
||||
stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
|
||||
return if status.success?
|
||||
|
||||
detail = stderr.to_s.strip
|
||||
detail = stdout.to_s.strip if detail.empty?
|
||||
UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
|
||||
end
|
||||
|
||||
def normalize_watch_screenshot_status_bar(path)
|
||||
script = <<~SWIFT
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let timeText = CommandLine.arguments[2]
|
||||
|
||||
guard let source = NSImage(contentsOfFile: path),
|
||||
let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
else {
|
||||
fputs("Failed to load screenshot at \\(path)\\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let width = CGFloat(cgImage.width)
|
||||
let height = CGFloat(cgImage.height)
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(width),
|
||||
pixelsHigh: Int(height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0),
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
else {
|
||||
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
bitmap.size = NSSize(width: width, height: height)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
from: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
|
||||
.foregroundColor: NSColor.white,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
timeText.draw(
|
||||
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
||||
withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:])
|
||||
else {
|
||||
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path))
|
||||
SWIFT
|
||||
|
||||
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
||||
file.write(script)
|
||||
file.flush
|
||||
sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
|
||||
end
|
||||
end
|
||||
|
||||
def capture_watch_screenshot
|
||||
device = resolve_simulator_device(watch_snapshot_device)
|
||||
device_name = device.fetch("name")
|
||||
udid = device.fetch("udid")
|
||||
output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
|
||||
output_path = File.join(output_dir, "#{device_name}-01-quick-reply.png")
|
||||
derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
|
||||
app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")
|
||||
|
||||
FileUtils.mkdir_p(output_dir)
|
||||
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
|
||||
FileUtils.rm_rf(derived_data_path)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
File.join(ios_root, "OpenClaw.xcodeproj"),
|
||||
"-scheme",
|
||||
"OpenClawWatchApp",
|
||||
"-configuration",
|
||||
"Debug",
|
||||
"-destination",
|
||||
"platform=watchOS Simulator,id=#{udid}",
|
||||
"-derivedDataPath",
|
||||
derived_data_path,
|
||||
"build",
|
||||
])
|
||||
)
|
||||
|
||||
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
|
||||
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
|
||||
watch_app_identifier = bundle_identifier_for_product(app_path)
|
||||
watch_extension_identifier = bundle_identifier_for_product(extension_path)
|
||||
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
|
||||
|
||||
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
|
||||
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
|
||||
sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
|
||||
status_bar_overridden = false
|
||||
begin
|
||||
sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
|
||||
write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
||||
status_bar_overridden = set_watch_status_bar_override(udid)
|
||||
sh(
|
||||
"SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"launch",
|
||||
udid,
|
||||
watch_app_identifier,
|
||||
"--openclaw-watch-screenshot-mode",
|
||||
])}"
|
||||
)
|
||||
sleep(3)
|
||||
sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
|
||||
normalize_watch_screenshot_status_bar(output_path)
|
||||
ensure
|
||||
clear_watch_status_bar_override(udid) if status_bar_overridden
|
||||
clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
|
||||
end
|
||||
|
||||
UI.success("Captured Apple Watch screenshot: #{output_path}")
|
||||
output_path
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
@@ -400,92 +103,6 @@ def ios_root
|
||||
File.expand_path("..", __dir__)
|
||||
end
|
||||
|
||||
def preserve_file(path)
|
||||
existed = File.exist?(path)
|
||||
contents = existed ? File.binread(path) : nil
|
||||
|
||||
yield
|
||||
ensure
|
||||
if existed
|
||||
File.binwrite(path, contents)
|
||||
else
|
||||
FileUtils.rm_f(path)
|
||||
end
|
||||
end
|
||||
|
||||
def preserve_local_signing
|
||||
preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def app_store_signing_manifest
|
||||
JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
|
||||
end
|
||||
|
||||
def app_store_provisioning_profiles
|
||||
app_store_signing_manifest.fetch("targets").each_with_object({}) do |target, profiles|
|
||||
profiles[target.fetch("bundleId")] = target.fetch("profileName")
|
||||
end
|
||||
end
|
||||
|
||||
def xml_string(value)
|
||||
CGI.escapeHTML(value.to_s)
|
||||
end
|
||||
|
||||
def write_app_store_export_options(path)
|
||||
manifest = app_store_signing_manifest
|
||||
profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
|
||||
" <key>#{xml_string(bundle_id)}</key>\n <string>#{xml_string(profile_name)}</string>"
|
||||
end.join("\n")
|
||||
|
||||
File.write(path, <<~PLIST)
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution</string>
|
||||
<key>teamID</key>
|
||||
<string>#{xml_string(manifest.fetch("teamId"))}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
#{profile_entries}
|
||||
</dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
end
|
||||
|
||||
def release_signing_check!
|
||||
sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "check"]))
|
||||
end
|
||||
|
||||
def release_notes_path
|
||||
File.join(__dir__, "metadata", "en-US", "release_notes.txt")
|
||||
end
|
||||
|
||||
def release_notes_metadata_path
|
||||
source = release_notes_path
|
||||
UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)
|
||||
|
||||
temp_root = Dir.mktmpdir("openclaw-release-notes")
|
||||
target_dir = File.join(temp_root, "en-US")
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
|
||||
temp_root
|
||||
end
|
||||
|
||||
def read_ios_version_metadata
|
||||
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
@@ -539,102 +156,69 @@ def shell_join(parts)
|
||||
Shellwords.join(parts.compact)
|
||||
end
|
||||
|
||||
def xcodebuild_shell_join(parts)
|
||||
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
|
||||
shell_join(["env", "PATH=#{xcode_path}", *parts])
|
||||
end
|
||||
|
||||
def resolve_release_build_number(api_key:, short_version:)
|
||||
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
||||
def resolve_beta_build_number(api_key:, short_version:)
|
||||
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||
if env_present?(explicit)
|
||||
UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||
UI.message("Using explicit iOS release build number #{explicit}.")
|
||||
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||
UI.message("Using explicit iOS beta build number #{explicit}.")
|
||||
return explicit
|
||||
end
|
||||
|
||||
latest_build = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_STORE_APP_IDENTIFIER,
|
||||
app_identifier: BETA_APP_IDENTIFIER,
|
||||
version: short_version,
|
||||
initial_build_number: 0
|
||||
)
|
||||
next_build = latest_build.to_i + 1
|
||||
UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
|
||||
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
|
||||
next_build.to_s
|
||||
end
|
||||
|
||||
def release_build_number_needs_asc_auth?
|
||||
explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
|
||||
def beta_build_number_needs_asc_auth?
|
||||
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||
!env_present?(explicit)
|
||||
end
|
||||
|
||||
def prepare_app_store_release!(version:, build_number:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
|
||||
UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
|
||||
def prepare_beta_release!(version:, build_number:)
|
||||
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
|
||||
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
|
||||
sh(shell_join(["bash", script_path, "--build-number", build_number]))
|
||||
|
||||
release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
|
||||
UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)
|
||||
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
|
||||
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
|
||||
|
||||
ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
|
||||
release_xcconfig
|
||||
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
|
||||
beta_xcconfig
|
||||
end
|
||||
|
||||
def build_app_store_release(context)
|
||||
def build_beta_release(context)
|
||||
version = context[:version]
|
||||
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
|
||||
output_directory = File.join(ios_root, "build", "app-store")
|
||||
output_directory = File.join("build", "beta")
|
||||
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
|
||||
export_options_path = File.join(output_directory, "ExportOptions.plist")
|
||||
output_name = "OpenClaw-#{version}.ipa"
|
||||
expected_ipa_path = File.join(output_directory, output_name)
|
||||
|
||||
FileUtils.mkdir_p(output_directory)
|
||||
FileUtils.rm_rf(archive_path)
|
||||
Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
|
||||
write_app_store_export_options(export_options_path)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
project_path,
|
||||
"-scheme",
|
||||
"OpenClaw",
|
||||
"-configuration",
|
||||
"Release",
|
||||
"-destination",
|
||||
"generic/platform=iOS",
|
||||
"-archivePath",
|
||||
archive_path,
|
||||
"clean",
|
||||
"archive",
|
||||
])
|
||||
build_app(
|
||||
project: "OpenClaw.xcodeproj",
|
||||
scheme: "OpenClaw",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
skip_profile_detection: true,
|
||||
build_path: "build",
|
||||
archive_path: archive_path,
|
||||
output_directory: output_directory,
|
||||
output_name: "OpenClaw-#{version}.ipa",
|
||||
xcargs: "-allowProvisioningUpdates",
|
||||
export_xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
signingStyle: "automatic"
|
||||
}
|
||||
)
|
||||
|
||||
sh(
|
||||
xcodebuild_shell_join([
|
||||
"xcodebuild",
|
||||
"-exportArchive",
|
||||
"-archivePath",
|
||||
archive_path,
|
||||
"-exportPath",
|
||||
output_directory,
|
||||
"-exportOptionsPlist",
|
||||
export_options_path,
|
||||
])
|
||||
)
|
||||
|
||||
exported_ipas = Dir[File.join(output_directory, "*.ipa")]
|
||||
UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
|
||||
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
|
||||
exported_ipa = exported_ipas.first
|
||||
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
|
||||
|
||||
{
|
||||
archive_path: archive_path,
|
||||
build_number: context[:build_number],
|
||||
ipa_path: expected_ipa_path,
|
||||
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
|
||||
short_version: context[:short_version],
|
||||
version: version
|
||||
}
|
||||
@@ -688,40 +272,40 @@ platform :ios do
|
||||
api_key
|
||||
end
|
||||
|
||||
private_lane :prepare_app_store_context do |options|
|
||||
private_lane :prepare_beta_context do |options|
|
||||
require_api_key = options[:require_api_key] == true
|
||||
needs_api_key = require_api_key || release_build_number_needs_asc_auth?
|
||||
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
|
||||
api_key = needs_api_key ? asc_api_key : nil
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
version = version_metadata[:version]
|
||||
short_version = version_metadata[:short_version]
|
||||
build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
|
||||
release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)
|
||||
build_number = resolve_beta_build_number(api_key: api_key, short_version: short_version)
|
||||
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
|
||||
|
||||
{
|
||||
api_key: api_key,
|
||||
beta_xcconfig: beta_xcconfig,
|
||||
build_number: build_number,
|
||||
release_xcconfig: release_xcconfig,
|
||||
short_version: short_version,
|
||||
version: version
|
||||
}
|
||||
end
|
||||
|
||||
desc "Build an App Store distribution archive locally without uploading"
|
||||
lane :app_store_archive do
|
||||
context = prepare_app_store_context(require_api_key: false)
|
||||
build = build_app_store_release(context)
|
||||
UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
desc "Build a beta archive locally without uploading"
|
||||
lane :beta_archive do
|
||||
context = prepare_beta_context(require_api_key: false)
|
||||
build = build_beta_release(context)
|
||||
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
build
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Build + upload an App Store distribution build to App Store Connect"
|
||||
lane :app_store do
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
desc "Build + upload a beta to TestFlight"
|
||||
lane :beta do
|
||||
context = prepare_beta_context(require_api_key: true)
|
||||
build = build_beta_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
@@ -730,33 +314,7 @@ platform :ios do
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
end
|
||||
ENV["DELIVER_SCREENSHOTS"] = "1"
|
||||
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
||||
metadata
|
||||
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
api_key: context[:api_key],
|
||||
ipa: build[:ipa_path],
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
UI.important("App Review submission remains manual in App Store Connect.")
|
||||
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
ensure
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
@@ -772,19 +330,8 @@ platform :ios do
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested?
|
||||
paths = screenshot_paths
|
||||
if paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
validate_required_screenshots!(paths)
|
||||
end
|
||||
|
||||
metadata_path = File.join(__dir__, "metadata")
|
||||
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
||||
if release_notes_upload_requested? && skip_metadata
|
||||
metadata_path = release_notes_metadata_path
|
||||
skip_metadata = false
|
||||
if screenshot_upload_requested? && screenshot_paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
|
||||
deliver_options = {
|
||||
@@ -794,13 +341,10 @@ platform :ios do
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
metadata_path: metadata_path,
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_metadata: skip_metadata,
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
skip_app_version_update: false,
|
||||
submit_for_review: false,
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
@@ -813,40 +357,6 @@ platform :ios do
|
||||
deliver(**deliver_options)
|
||||
end
|
||||
|
||||
desc "Generate deterministic iOS screenshots for App Store metadata"
|
||||
lane :screenshots do
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
||||
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
||||
|
||||
capture_ios_screenshots(
|
||||
project: File.join(ios_root, "OpenClaw.xcodeproj"),
|
||||
scheme: "OpenClawUITests",
|
||||
configuration: "Debug",
|
||||
devices: snapshot_devices,
|
||||
languages: ["en-US"],
|
||||
launch_arguments: ["--openclaw-screenshot-mode"],
|
||||
output_directory: File.join(ios_root, "fastlane", "screenshots"),
|
||||
clear_previous_screenshots: true,
|
||||
reinstall_app: true,
|
||||
concurrent_simulators: false,
|
||||
override_status_bar: true,
|
||||
override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
|
||||
skip_open_summary: true,
|
||||
xcargs: "-allowProvisioningUpdates"
|
||||
)
|
||||
|
||||
watch_screenshot
|
||||
end
|
||||
|
||||
desc "Generate deterministic Apple Watch screenshot for App Store metadata"
|
||||
lane :watch_screenshot do
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
|
||||
sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
|
||||
capture_watch_screenshot
|
||||
end
|
||||
|
||||
desc "Validate App Store Connect API auth"
|
||||
lane :auth_check do
|
||||
asc_api_key
|
||||
|
||||
@@ -29,12 +29,12 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
|
||||
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||
```
|
||||
|
||||
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional release-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
|
||||
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
|
||||
|
||||
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
||||
|
||||
```bash
|
||||
ASC_APP_IDENTIFIER=ai.openclawfoundation.app
|
||||
ASC_APP_IDENTIFIER=ai.openclaw.client
|
||||
# or
|
||||
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
|
||||
```
|
||||
@@ -53,26 +53,7 @@ Code signing variable (optional in `.env`):
|
||||
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
|
||||
```
|
||||
|
||||
Tip: run `scripts/ios-team-id.sh --require-canonical` from repo root to verify the canonical OpenClaw iOS team (`FWJYW4S8P8`) is available locally. Fastlane uses the same canonical-only path when `IOS_DEVELOPMENT_TEAM` is missing, and rejects non-canonical teams for release archives.
|
||||
|
||||
App Store release signing is manual and profile-pinned. The canonical manifest is `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
One-time or rotation setup:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:signing:plan
|
||||
pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
```bash
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
|
||||
ASC_MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
|
||||
```
|
||||
|
||||
The signing repo is private and encrypted. Store `ASC_MATCH_PASSWORD` in the release-owner vault, not in this product repo. `sync:pull` writes decrypted assets under `apps/ios/build/signing/`; import the distribution certificate/private key into Keychain before archiving.
|
||||
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
|
||||
|
||||
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
|
||||
|
||||
@@ -85,36 +66,28 @@ fastlane ios auth_check
|
||||
|
||||
ASC auth is only required when:
|
||||
|
||||
- uploading to App Store Connect
|
||||
- uploading to TestFlight
|
||||
- auto-resolving the next build number from App Store Connect
|
||||
|
||||
If you pass `--build-number` to `pnpm ios:release:archive`, the local archive path does not need ASC auth.
|
||||
If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
|
||||
|
||||
Archive locally without upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:archive
|
||||
pnpm ios:beta:archive
|
||||
```
|
||||
|
||||
Generate deterministic App Store screenshots:
|
||||
Upload to TestFlight:
|
||||
|
||||
```bash
|
||||
pnpm ios:screenshots
|
||||
```
|
||||
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
|
||||
Upload to App Store Connect:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
Direct Fastlane entry point:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios release_upload
|
||||
fastlane ios beta
|
||||
```
|
||||
|
||||
Maintainer recovery path for a fresh clone on the same Mac:
|
||||
@@ -142,7 +115,7 @@ fastlane ios auth_check
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Set the official relay URL before release:
|
||||
5. Set the official/TestFlight relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
@@ -151,14 +124,14 @@ export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
6. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
|
||||
- confirm `apps/ios/build/beta/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane prints `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
- remember that TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
Versioning rules:
|
||||
|
||||
@@ -168,10 +141,9 @@ Versioning rules:
|
||||
- `pnpm ios:version:pin -- --from-gateway` promotes the current root gateway version into the pinned iOS release version
|
||||
- Fastlane uses the pinned iOS version only; changing `package.json.version` alone does not change the iOS app version
|
||||
- Fastlane sets `CFBundleShortVersionString` to the pinned iOS version, for example `2026.4.10`
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer App Store Connect build number for that short version
|
||||
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
|
||||
- Run `pnpm ios:version:sync` after changing `apps/ios/version.json` or `apps/ios/CHANGELOG.md`
|
||||
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
|
||||
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
project("OpenClaw.xcodeproj")
|
||||
scheme("OpenClawUITests")
|
||||
configuration("Debug")
|
||||
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPad Pro 13-inch (M4)",
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
])
|
||||
|
||||
launch_arguments([
|
||||
"--openclaw-screenshot-mode",
|
||||
])
|
||||
|
||||
output_directory("fastlane/screenshots")
|
||||
clear_previous_screenshots(true)
|
||||
reinstall_app(true)
|
||||
concurrent_simulators(false)
|
||||
override_status_bar(true)
|
||||
override_status_bar_arguments("--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100")
|
||||
skip_open_summary(true)
|
||||
@@ -10,15 +10,6 @@ ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||
DELIVER_METADATA=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Release notes only
|
||||
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
DELIVER_RELEASE_NOTES=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Optional: include screenshots
|
||||
|
||||
```bash
|
||||
@@ -48,7 +39,6 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
|
||||
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
|
||||
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
|
||||
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `ASC_APP_IDENTIFIER` (bundle ID)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: OpenClaw
|
||||
options:
|
||||
bundleIdPrefix: ai.openclawfoundation
|
||||
bundleIdPrefix: ai.openclaw
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -37,19 +37,6 @@ schemes:
|
||||
test:
|
||||
targets:
|
||||
- OpenClawLogicTests
|
||||
OpenClawUITests:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawUITests: all
|
||||
test:
|
||||
targets:
|
||||
- OpenClawUITests
|
||||
OpenClawWatchApp:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawWatchApp: all
|
||||
|
||||
targets:
|
||||
OpenClaw:
|
||||
@@ -104,7 +91,7 @@ targets:
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
@@ -118,13 +105,11 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
Release:
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
@@ -135,7 +120,7 @@ targets:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: ai.openclawfoundation.app
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
@@ -148,7 +133,7 @@ targets:
|
||||
- audio
|
||||
- remote-notification
|
||||
BGTaskSchedulerPermittedIdentifiers:
|
||||
- "$(OPENCLAW_APP_BUNDLE_ID).bgrefresh"
|
||||
- ai.openclaw.ios.bgrefresh
|
||||
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
@@ -191,7 +176,7 @@ targets:
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -231,11 +216,10 @@ targets:
|
||||
- sdk: ActivityKit.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
@@ -267,7 +251,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -301,7 +285,7 @@ targets:
|
||||
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
|
||||
@@ -334,10 +318,10 @@ targets:
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).tests"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
@@ -362,10 +346,10 @@ targets:
|
||||
- package: OpenClawKit
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).logic-tests"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_EMIT_CONST_VALUE_PROTOCOLS: ""
|
||||
SWIFT_VERSION: "6.0"
|
||||
@@ -376,32 +360,3 @@ targets:
|
||||
CFBundleDisplayName: OpenClawLogicTests
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
|
||||
OpenClawUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: UITests
|
||||
dependencies:
|
||||
- target: OpenClaw
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).ui-tests"
|
||||
SDKROOT: iphoneos
|
||||
SUPPORTED_PLATFORMS: "iphonesimulator iphoneos"
|
||||
SUPPORTS_MACCATALYST: NO
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TEST_TARGET_NAME: OpenClaw
|
||||
SWIFT_VERSION: "5.0"
|
||||
info:
|
||||
path: UITests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawUITests
|
||||
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "4f7b315ce0e0a16d150d8d74dce445628c03d8926485ad2f5595e091b4d33440",
|
||||
"originHash" : "ae9f37f50cff0d32d189e60948f61e2fa1704e997a6ef4ad5e37f6a11c165ea4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -42,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"revision" : "1fa8eead7eeac3ff618a3111fc333ae78db043d2",
|
||||
"version" : "3.5.2"
|
||||
"revision" : "ee0e3185431788dad533ffca77cd75315aa3d26f",
|
||||
"version" : "3.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.1"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../swabble"),
|
||||
],
|
||||
|
||||
@@ -46,17 +46,6 @@ public enum NodePresenceAliveReason: String, Codable, Sendable {
|
||||
case connect = "connect"
|
||||
}
|
||||
|
||||
public enum SessionFileKind: String, Codable, Sendable {
|
||||
case modified = "modified"
|
||||
case read = "read"
|
||||
}
|
||||
|
||||
public enum SessionFileRelevance: String, Codable, Sendable {
|
||||
case modified = "modified"
|
||||
case read = "read"
|
||||
case mixed = "mixed"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
@@ -1767,7 +1756,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let spawnedby: String?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let allowmissing: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
@@ -1776,8 +1764,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
agentid: String? = nil,
|
||||
spawnedby: String?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
allowmissing: Bool? = nil)
|
||||
includeunknown: Bool?)
|
||||
{
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
@@ -1786,7 +1773,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
self.spawnedby = spawnedby
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.allowmissing = allowmissing
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1797,7 +1783,6 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
case spawnedby = "spawnedBy"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case allowmissing = "allowMissing"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user