Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

18
apps/ios/.periphery.yml Normal file
View File

@@ -0,0 +1,18 @@
project: OpenClaw.xcodeproj
schemes:
- OpenClaw
retain_codable_properties: true
retain_swift_ui_previews: true
retain_objc_accessible: true
retain_unused_protocol_func_params: true
retain_assign_only_properties: true
relative_results: true
disable_update_check: true
report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchApp/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

25
apps/ios/.swiftlint.yml Normal file
View File

@@ -0,0 +1,25 @@
parent_config: ../../config/swiftlint.yml
included:
- Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

36
apps/ios/AGENTS.md Normal file
View File

@@ -0,0 +1,36 @@
# iOS Release Agent Policy
Root rules still apply. This file adds the iOS release guardrails.
## UI / Typography
- iOS SwiftUI text should use branded typography helpers, not bare system fonts. Use `OpenClawType` in `apps/ios/Sources/**`, `OpenClawChatTypography` in shared chat UI, `WatchClawType` in watch UI, and `OpenClawActivityType` in Live Activity UI.
- Apply branded fonts at the rendered text/control boundary when practical: `Text`, `Label`, `Button` labels, picker options, menu items, alert text/buttons, row titles/subtitles, placeholder overlays, chips, badges, and toolbar actions. Do not rely on distant parent `.font(...)` inheritance for user-visible text when a local modifier is cheap and clear.
- Avoid shorthand controls like `Button("Title")`, `Link("Title", ...)`, `TextField("Placeholder", ...)`, and `SecureField("Placeholder", ...)` when they make typography or placeholder styling implicit. Prefer explicit label builders with branded `Text`/`Label`.
- Secure-field placeholders need both branded visual styling and accessibility semantics. If using an overlay placeholder, keep the actual field semantically named with `.accessibilityLabel(...)` and hide the decorative placeholder from accessibility.
- System font modifiers are acceptable for SF Symbol `Image(systemName:)` sizing, not for user-visible text unless a platform control makes branded typography impossible. If an exception is intentional, keep it narrow and explain why.
- When touching iOS text surfaces, update/keep `apps/ios/Tests/OpenClawTypographyTests.swift` so bare text/control regressions are caught, then run the focused typography test.
## Licenses Screen
- Maintain the Settings-tab Licenses screen when iOS app dependencies change.
- Bundled license files live in `apps/ios/Resources/Licenses/`.
- License files must be UTF-8 `.txt` files. Do not add Markdown, HTML, RTF, or generated plist license content.
- The Licenses screen discovers bundled `.txt` files at runtime through `LicenseDocumentLoader`; do not hardcode individual license rows in Swift.
- License rows are ordered alphabetically in code by derived display title. Do not use numeric filename prefixes for ordering.
- Filenames should be plain dependency names, for example `WebRTC.txt`; the filename is used only to derive the row title and must not be shown as a row subtitle.
- Do not add OpenClaw, OpenClaw Foundation, or other first-party/self-owned license entries. The screen is for third-party/open-source dependency acknowledgements.
- When adding, removing, or upgrading iOS dependencies, audit whether `apps/ios/Resources/Licenses/` needs updates. Exclude dependencies owned by OpenClaw Foundation from the published license list.
- When adding, removing, or replacing redistributed font binaries under `apps/ios/Sources/Fonts/`, update `apps/ios/THIRD_PARTY_FONTS.md` with immutable upstream source URLs and SHA-256 checksums for each bundled file.
- Keep license detail bodies rendered as verbatim monospace text.
- Keep the Settings Licenses row at the bottom Settings section with no section title unless product direction changes.
- When changing license loading or presentation, update `apps/ios/Tests/LicenseDocumentLoaderTests.swift` and `apps/ios/Tests/SwiftUIRenderSmokeTests.swift`, then run focused iOS tests.
## App Store Releases
- Agent-driven App Store uploads must use only `pnpm ios:release:upload`.
- App Store uploads must include explicit release intent: `pnpm ios:release:upload -- --version <YYYY.M.D>` and `--build-number <n>` when a specific build has been chosen.
- If `pnpm ios:release:upload` exits non-zero, stop immediately and report the failing step.
- After a failed `pnpm ios:release:upload`, do not continue with `pnpm ios:release:archive`, `asc builds upload`, `asc release stage`, `asc publish appstore`, `asc review submit`, direct Fastlane lanes, or any manual App Store Connect mutation command.
- Do not submit an iOS App Store version for App Review. App Review submission stays manual unless the user explicitly asks to submit a specific already-prepared version after the failed state has been reported.
- `pnpm ios:release:archive` is for local archive validation only. It is not a fallback release path after screenshot, metadata, or upload-lane failure.

View File

@@ -0,0 +1,198 @@
# App Review Notes
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
## Demo Account / Setup
Use the OpenClaw iOS app with the live review Gateway setup code included in
the `Notes` field of this App Review submission.
The setup code is a single generated code string. It already contains the public
Gateway host and setup credential.
## Setup Walkthrough
1. Open the OpenClaw app.
2. Tap `Continue`.
3. On `Connect Gateway`, tap `Set Up Manually`.
4. In the `Setup Code` section, tap the `Paste setup code` field.
5. Paste the setup code string from the App Review submission `Notes` field.
6. Tap `Apply Setup Code`.
7. If `Trust and connect` appears, tap `Trust and connect`.
8. Wait for the `Connected` screen.
9. On `Connected`, tap `Open OpenClaw`.
10. Confirm the `Control` screen shows `Gateway Online`.
11. Tap `Settings`.
12. Tap `Approvals`.
13. Tap `Open Notifications`.
14. Tap `Enable Notifications`.
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
17. Confirm `Notifications` shows `Enabled`.
## Chat
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start Apple review checklist.
```
Expected result: the assistant replies with the available App Review demos.
## Approval Demo
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Run the approval demo.
```
Expected result: the iPhone shows `Exec approval required` with the harmless
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
`Allow Once`. The chat then replies:
```text
The approval demo completed.
```
## Talk
1. Tap the `Talk` tab.
2. Tap `Start Talk`.
3. If iOS asks for microphone access, tap `Allow`.
4. If iOS asks for Speech Recognition access, tap `Allow`.
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
6. Say:
```text
Summarize this review setup in one sentence.
```
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm the speaker button is highlighted.
3. Confirm the background-listening button is highlighted.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
7. Confirm `Stop Talk` is visible.
8. Say:
```text
Tell me when you can hear me.
```
9. While Talk is active, send OpenClaw to the background by returning to the
Home Screen or locking the iPhone. Do not force quit the app.
10. Continue speaking then wait for assistant audio reply.
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
## Gateway Status
1. Tap `Control`.
2. Tap `Instances`.
3. Confirm the screen shows `Gateway online`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Live Activity / Dynamic Island
1. Tap `Settings`.
2. Tap `Reconnect`.
3. Immediately send OpenClaw to the background by returning to the Home Screen
or locking the iPhone.
4. Watch the Lock Screen or Dynamic Island while the Gateway reconnects.
Expected result: while reconnecting, iOS can show an `OpenClaw` Live Activity
with connection status such as `Connecting...` or `Reconnecting...`. On a fast
network this status may be brief because OpenClaw ends the Live Activity after
the Gateway reconnects successfully.
## Push Notification
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start push notification demo.
```
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
notification with this body:
```text
OpenClaw App Review push notification demo
```
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`. Expected chat reply:
```text
The push notification demo completed.
```
## Push Wake / Status
1. Tap the `Chat` tab.
2. Send this exact message:
```text
Start push wake demo.
```
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
about 10 seconds after the message is sent.
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`.
Expected result: the app reconnects to the live Gateway and Chat replies:
```text
The push wake and node status demo completed.
```
## Device Permissions
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
- `Camera`
- `Location` with `Off`, `While Using`, and `Always`
- `Keep Awake`
4. Expand `Privacy & Access`.
5. Confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
## Share Sheet
1. Open Safari.
2. Navigate to `https://example.com`.
3. Tap the Safari toolbar `More` button.
4. Tap `Share`.
5. Tap `OpenClaw`.
6. Confirm the OpenClaw share extension appears and shows
`Edit text, then tap Send.` and `Send to OpenClaw`.
7. Tap `Send to OpenClaw`.
Expected result: the OpenClaw share extension sends the shared Safari page to
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
Chat shows the shared `Example Domain` page.

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,40 @@
<?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>OpenClaw Activity</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>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>RedHatDisplay[wght].ttf</string>
<string>Inter[opsz,wght].ttf</string>
<string>Inter-Italic[opsz,wght].ttf</string>
<string>JetBrainsMono-Regular.ttf</string>
<string>JetBrainsMono-Medium.ttf</string>
<string>JetBrainsMono-SemiBold.ttf</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,27 @@
import SwiftUI
enum OpenClawActivityType {
static var subheadSemiBold: Font {
display(size: 15, weight: .semibold)
}
static var subheadBold: Font {
display(size: 15, weight: .bold)
}
static var caption: Font {
body(size: 12)
}
static func symbol(size: CGFloat, weight: Font.Weight) -> Font {
.system(size: size, weight: weight)
}
private static func display(size: CGFloat, weight: Font.Weight) -> Font {
.custom("RedHatDisplay-Regular", size: size, relativeTo: .subheadline).weight(weight)
}
private static func body(size: CGFloat) -> Font {
.custom("Inter-Regular", size: size, relativeTo: .caption)
}
}

View File

@@ -0,0 +1,9 @@
import SwiftUI
import WidgetKit
@main
struct OpenClawActivityWidgetBundle: WidgetBundle {
var body: some Widget {
OpenClawLiveActivity()
}
}

View File

@@ -0,0 +1,103 @@
import ActivityKit
import SwiftUI
import WidgetKit
struct OpenClawLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
self.lockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
self.statusDot(state: context.state)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.statusText)
.font(OpenClawActivityType.subheadSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
DynamicIslandExpandedRegion(.trailing) {
self.trailingView(state: context.state)
}
} compactLeading: {
self.statusDot(state: context.state)
} compactTrailing: {
self.compactStatusIcon(state: context.state)
} minimal: {
self.statusDot(state: context.state)
}
}
}
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
HStack(spacing: 10) {
self.statusIcon(state: context.state)
.frame(width: 30, height: 30)
.background(.thinMaterial, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw")
.font(OpenClawActivityType.subheadBold)
.lineLimit(1)
Text(context.state.statusText)
.font(OpenClawActivityType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer()
self.trailingView(state: context.state)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View {
self.statusIcon(state: state)
.font(OpenClawActivityType.symbol(size: 16, weight: .semibold))
.frame(width: 28, height: 28)
}
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
Circle()
.fill(self.dotColor(state: state))
.frame(width: 6, height: 6)
}
private func compactStatusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
self.statusIcon(state: state)
.font(OpenClawActivityType.symbol(size: 12, weight: .semibold))
.frame(width: 18, height: 18)
}
@ViewBuilder
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -0,0 +1 @@
"CFBundleDisplayName" = "OpenClaw-aktivitet";

180
apps/ios/CHANGELOG.md Normal file
View File

@@ -0,0 +1,180 @@
# OpenClaw iOS Changelog
## Unreleased
- Debug builds now use separate app, extension, widget, and Watch identifiers plus a distinct debug icon, so they can remain installed beside release builds.
- Animated the OpenClaw mascot (float, blink, antenna wiggle, claw snaps) across onboarding, sidebar, and command center, matching openclaw.ai.
## 2026.6.11 - 2026-07-01
Maintenance update for the current OpenClaw release.
- Refreshed iOS 26 visual styling, Talk controls, Gateway recovery, localization, and App Store screenshots.
## 2026.6.10 - 2026-06-21
Maintenance update for the current OpenClaw release.
- Improved notification cleanup, Watch app compatibility, and native file input handling.
## 2026.6.9 - 2026-06-20
Maintenance update for the current OpenClaw release.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on iPhone.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.
## 2026.6.1 - 2026-06-01
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
## 2026.5.28 - 2026-05-28
Maintenance update for the current OpenClaw release.
## 2026.5.27 - 2026-05-27
Maintenance update for the current OpenClaw release.
## 2026.5.26 - 2026-05-26
Maintenance update for the current OpenClaw release.
## 2026.5.25 - 2026-05-25
Maintenance update for the current OpenClaw release.
## 2026.5.24 - 2026-05-24
Maintenance update for the current OpenClaw release.
## 2026.5.22 - 2026-05-22
Maintenance update for the current OpenClaw release.
## 2026.5.21 - 2026-05-21
Maintenance update for the current OpenClaw release.
- Added realtime Gateway Talk relay support for iOS voice sessions, including OpenAI realtime provider and voice selection controls. Thanks @Solvely-Colin.
## 2026.5.20 - 2026-05-20
Maintenance update for the current OpenClaw release.
## 2026.5.19 - 2026-05-19
Maintenance update for the current OpenClaw release.
## 2026.5.17 - 2026-05-17
Maintenance update for the current OpenClaw release.
## 2026.5.12 - 2026-05-12
Maintenance update for the current OpenClaw release.
## 2026.5.10 - 2026-05-10
Maintenance update for the current OpenClaw release.
- Gateway connections now recover after a trusted Gateway certificate changes by refreshing the stored certificate pin during reconnect.
## 2026.5.8 - 2026-05-08
Maintenance update for the current OpenClaw development release.
## 2026.5.6 - 2026-05-06
Maintenance update for the current OpenClaw development release.
## 2026.5.5 - 2026-05-05
Maintenance update for the current OpenClaw development release.
## 2026.5.4 - 2026-05-04
Maintenance update for the current OpenClaw development release.
- Gateway pairing now supports scanning QR codes from Settings and accepts full copied setup-code messages while keeping non-loopback `ws://` setup links blocked.
## 2026.5.3 - 2026-05-03
Maintenance update for the current OpenClaw development release.
## 2026.5.2 - 2026-05-02
Maintenance update for the current OpenClaw development release.
## 2026.4.30 - 2026-04-30
Maintenance update for the current OpenClaw development release.
## 2026.4.27 - 2026-04-27
Maintenance update for the current OpenClaw development release.
## 2026.4.26 - 2026-04-26
Maintenance update for the current OpenClaw development release.
- Refreshed build hygiene for the iOS app, Share extension, Activity widget, Watch app, and curated shared Swift sources; relay registration now uses StoreKit app transaction JWS data instead of deprecated receipt APIs.
## 2026.4.25 - 2026-04-25
Maintenance update for the current OpenClaw development release.
## 2026.4.23 - 2026-04-23
Maintenance update for the current OpenClaw development release.
## 2026.4.22 - 2026-04-22
Maintenance update for the current OpenClaw development release.
## 2026.4.21 - 2026-04-21
Maintenance update for the current OpenClaw development release.
## 2026.4.20 - 2026-04-20
Maintenance update for the current OpenClaw release.
## 2026.4.19 - 2026-04-19
Maintenance update for the current OpenClaw release.
## 2026.4.18 - 2026-04-18
Maintenance update for the current OpenClaw release.
## 2026.4.15 - 2026-04-15
Maintenance update for the current OpenClaw release.
## 2026.4.14 - 2026-04-14
Maintenance update for the current OpenClaw release.
## 2026.4.12 - 2026-04-12
Maintenance update for the current OpenClaw release.
## 2026.4.10 - 2026-04-10
Maintenance update for the current OpenClaw release.
## 2026.4.6 - 2026-04-06
First App Store release of OpenClaw for iPhone. Pair with your OpenClaw Gateway to use chat, voice, sharing, and device actions from iOS.

1
apps/ios/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -0,0 +1,47 @@
{
"teamId": "FWJYW4S8P8",
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"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", "APP_GROUPS", "APP_ATTEST"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"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": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{
"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": []
}
]
}

View File

@@ -0,0 +1,33 @@
// Shared iOS signing defaults for local development + CI.
#include "../build/Version.xcconfig"
OPENCLAW_IOS_DEFAULT_TEAM = FWJYW4S8P8
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
#include? "../.local-signing.xcconfig"
#include? "../LocalSigning.xcconfig"
// Debug products install alongside release products and keep their app-group data separate.
OPENCLAW_DEBUG_APP_BUNDLE_ID = $(OPENCLAW_APP_BUNDLE_ID).debug
OPENCLAW_DEBUG_ACTIVITY_WIDGET_BUNDLE_ID = $(OPENCLAW_DEBUG_APP_BUNDLE_ID).activitywidget
OPENCLAW_DEBUG_WATCH_APP_BUNDLE_ID = $(OPENCLAW_DEBUG_APP_BUNDLE_ID).watchkitapp
OPENCLAW_DEBUG_APP_GROUP_ID = $(OPENCLAW_APP_GROUP_ID).debug
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
CODE_SIGN_IDENTITY = $(OPENCLAW_CODE_SIGN_IDENTITY)
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
PROVISIONING_PROFILE_SPECIFIER =

55
apps/ios/DESIGN.md Normal file
View File

@@ -0,0 +1,55 @@
# iOS design system
OpenClaw follows the native iOS 26 design language while keeping an iOS 18 deployment target. Use SwiftUI system structure first, Liquid Glass for interactive chrome, and quiet opaque surfaces for content.
## Principles
- Prefer `NavigationStack`, `TabView`, `List`, `Form`, toolbars, sheets, and system controls. They adopt the current platform appearance automatically.
- Reserve Liquid Glass for navigation and interactive controls. Do not apply glass to every card, row, or status surface.
- Keep content hierarchy clear with typography, spacing, and grouping before adding backgrounds.
- Use semantic colors. Red means destructive or stopped; orange means attention; green means healthy. Neutral actions use the app accent.
- Preserve Dynamic Type, VoiceOver labels, Reduce Motion, increased contrast, and 44-point touch targets.
- Use continuous corners and concentric geometry. Nested controls should visually follow their container shape.
Apple references: [Adopting Liquid Glass](https://developer.apple.com/documentation/technologyoverviews/adopting-liquid-glass), [Applying Liquid Glass to custom views](https://developer.apple.com/documentation/swiftui/applying-liquid-glass-to-custom-views), and [Build a SwiftUI app with the new design](https://developer.apple.com/videos/play/wwdc2025/323/).
## Tokens
`OpenClawProMetric` in `Sources/Design/OpenClawProComponents.swift` is the source of truth for shared geometry:
- `pagePadding`: standard page gutter
- `cardRadius`: content group radius
- `controlRadius`: inset control radius
- `compactControlSize`: compact circular control size
- `bottomScrollInset`: clearance above persistent navigation
Feature-local layout enums may define row heights and grid dimensions, but should reference the shared radius instead of introducing a new card shape.
## Components
- `OpenClawProBackground`: grouped page background
- `ProCard`: quiet content grouping; never Liquid Glass
- `ProIconBadge`, `ProValuePill`: compact semantic indicators
- `OpenClawNoticeBanner`: shared connection and runtime notices
- `OpenClawAdaptiveHeaderRow`: responsive destination heading
- `OpenClawGlassControlGroup`: performance and morphing boundary for nearby glass controls
- `openClawGlassButton(prominent:tint:)`: iOS 26 glass button with an iOS 18 bordered fallback
- `openClawTabBarBehavior()`: iOS 26 tab-bar minimization with an earlier-system no-op
## Liquid Glass rules
Use `openClawGlassButton` for primary actions, compact header controls, and navigation-adjacent controls. Use the prominent style for one primary action per region. Wrap nearby controls in `OpenClawGlassControlGroup`.
Do not place Liquid Glass behind reading content, forms, metrics, or every card in a scroll view. Excess glass weakens hierarchy, increases rendering cost, and competes with the system tab bar and navigation chrome.
Keep new iOS APIs behind `#available(iOS 26.0, *)`. The fallback must preserve the same label, action, tint meaning, accessibility, and approximate hit target.
## Review checklist
- Uses a native container or control where one exists.
- Uses shared spacing and corner tokens.
- Has one obvious primary action.
- Keeps semantic color independent from decoration.
- Works in light and dark mode, Dynamic Type, and compact phone layouts.
- Verifies iOS 26 appearance in the simulator and preserves the iOS 18 fallback path.
- Adds matched before/after evidence for a visual change.

View File

@@ -0,0 +1,18 @@
// Copy to LocalSigning.xcconfig for personal local signing overrides.
// 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_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =

369
apps/ios/README.md Normal file
View File

@@ -0,0 +1,369 @@
# OpenClaw iOS
OpenClaw iOS is the officially released iPhone app. It connects to an OpenClaw Gateway as a `role: node` for chat, voice, approvals, sharing, and device-aware automation.
## Distribution Status
- Public distribution: App Store.
- App Store Connect uploads use the App Store release Fastlane path.
- Local/manual deploy from source via Xcode remains the default development path for app development.
## Support Notes
- UI and onboarding changes ship through normal app releases.
- Some node commands require foreground access because of iOS platform limits.
- Permissions, background behavior, and push delivery are documented below so release and support checks stay explicit.
## Exact Xcode Manual Deploy Flow
1. Prereqs:
- Xcode 26.x
- `pnpm`
- `xcodegen`
- Apple Development signing set up in Xcode
2. From repo root:
```bash
pnpm install
pnpm ios:open
```
3. In Xcode:
- Scheme: `OpenClaw`
- Destination: connected iPhone (recommended for real behavior)
- Build configuration: `Debug`
- Run (`Product` -> `Run`)
4. If signing fails on a personal team:
- Use unique local bundle IDs via `apps/ios/LocalSigning.xcconfig`.
- Start from `apps/ios/LocalSigning.xcconfig.example`.
Generate without opening Xcode:
```bash
pnpm ios:gen
```
## App Store Release Flow
Prereqs:
- Xcode 26.x
- `pnpm`
- `xcodegen`
- `fastlane`
- Apple account signed into Xcode for the canonical OpenClaw team (`FWJYW4S8P8`)
- Fastlane Apple Developer Portal session for the canonical OpenClaw team when creating bundle IDs or enabling services
- Release-owner access to the encrypted signing repo password (`MATCH_PASSWORD`)
- App Store Connect app already created for `ai.openclawfoundation.app`
- App Store Connect API key set up in Keychain via `scripts/ios-app-store-connect-keychain-setup.sh` when auto-resolving a build number or uploading to App Store Connect
Release behavior:
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF before archiving and uploading the IPA.
- Agent-driven App Store uploads must use `pnpm ios:release:upload` as the only release path. If that command fails, stop and fix the failing screenshot, metadata, archive, validation, or upload step before trying again.
- Do not treat `pnpm ios:release:archive`, `asc builds upload`, `asc release stage`, `asc publish appstore`, direct Fastlane lanes, or App Store Connect mutation commands as fallback upload paths after `pnpm ios:release:upload` fails.
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Release uploads require an explicit CalVer version passed with `--version`.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
- The release version must use CalVer like `2026.4.10`.
- That release value becomes:
- `CFBundleShortVersionString = 2026.4.10`
- `CFBundleVersion = next App Store Connect build number for 2026.4.10`
- Local defaults derive from root `package.json`; App Store uploads use the explicit `--version` value.
- See `apps/ios/VERSIONING.md` for the full workflow.
Relay behavior for App Store builds:
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
Signing setup commands:
```bash
pnpm ios:release:signing:plan
pnpm ios:release:signing:check
pnpm ios:release:signing:setup
MATCH_PASSWORD=... pnpm ios:release:signing:sync:push
MATCH_PASSWORD=... pnpm ios:release:signing:sync:pull
```
Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
Prepare the generated release xcconfig/project without archiving:
```bash
pnpm ios:release:prepare -- --version 2026.6.11 --build-number 7
```
Archive without upload:
```bash
pnpm ios:release:archive -- --version 2026.6.11
```
This command is for local archive validation only. It is not a fallback upload
path after `pnpm ios:release:upload` fails.
Archive and upload to App Store Connect:
```bash
pnpm ios:release:upload -- --version 2026.6.11
```
If you need to force a specific build number:
```bash
pnpm ios:release:upload -- --version 2026.6.11 --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.
1. Confirm Fastlane auth is set up:
```bash
cd apps/ios
fastlane ios auth_check
```
2. If auth is missing, bootstrap it once on this Mac:
```bash
scripts/ios-app-store-connect-keychain-setup.sh \
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
--issuer-id YOUR_ISSUER_ID \
--write-env
```
This should create `apps/ios/fastlane/.env` with non-secret App Store Connect 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`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
4. If you are starting a brand-new production release train, add or update the matching iOS changelog section and validate the release notes:
```bash
pnpm ios:version:check -- --version 2026.6.11
```
5. Upload the build with explicit release intent:
```bash
pnpm ios:release:upload -- --version 2026.6.11 --build-number 3
```
6. If `pnpm ios:release:upload` fails, stop at that failure. Do not archive
and upload the IPA through another command. Fix the failing release-lane
step, then rerun `pnpm ios:release:upload`.
7. Expected behavior:
- Fastlane reads the explicit `--version` value
- validates iOS versioning inputs for that version
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
- uploads the IPA to App Store Connect for processing and App Review use
- leaves App Review submission for a maintainer to complete manually
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>`
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
- Release upload version: explicit `--version`
- Local default version: root `package.json`
- iOS-only changelog: `apps/ios/CHANGELOG.md`
- Generated local artifacts:
- `apps/ios/build/Version.xcconfig`
- `apps/ios/SwiftSources.input.xcfilelist`
- temporary Fastlane metadata containing release notes rendered from `apps/ios/CHANGELOG.md`
- Useful commands:
```bash
pnpm ios:version
pnpm ios:version:check
pnpm ios:version -- --version 2026.6.11
pnpm ios:filelist:gen
```
Recommended flow:
### App Store Connect iteration on an existing train
1. Choose the App Store train explicitly, for example `2026.6.11`.
2. Update `apps/ios/CHANGELOG.md`, usually under `## Unreleased` while iterating.
3. Run `pnpm ios:version:check -- --version 2026.6.11` after changelog changes.
4. Upload additional App Store Connect builds with `pnpm ios:release:upload -- --version 2026.6.11`.
5. Let Fastlane bump only the numeric build number.
### Starting the next production release train
1. Confirm the target gateway version in root `package.json`.
2. Update `apps/ios/CHANGELOG.md` for the new release as needed.
3. Run `pnpm ios:version:check -- --version <release-version>`.
4. Submit the first App Store Connect build with `pnpm ios:release:upload -- --version <release-version>`.
5. Keep iterating on that same explicit version until the release candidate is ready.
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.
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
- 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`.
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
## APNs Expectations For Official Builds
- Official App Store builds register with the external push relay before they publish `push.apns.register` to the gateway.
- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model
- `iOS -> gateway`
- The app must pair with the gateway and establish both node and operator sessions.
- The operator session is used to fetch `gateway.identity.get`.
- `iOS -> relay`
- The app registers with the relay over HTTPS using App Attest plus a StoreKit app transaction JWS.
- The relay requires the official App Store distribution path, which is why local
Xcode/dev installs cannot use the hosted relay.
- `gateway delegation`
- The app includes the gateway identity in relay registration.
- The relay returns a relay handle and registration-scoped send grant delegated to that gateway.
- `gateway -> relay`
- The gateway signs relay send requests with its own device identity.
- The relay verifies both the delegated send grant and the gateway signature before it sends to
APNs.
- `relay -> APNs`
- Production APNs credentials and raw official-build APNs tokens stay in the relay deployment,
not on the gateway.
This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a
gateway can only send pushes for iOS devices that paired with that gateway.
## What Works Now (Concrete)
- Pairing via QR or setup code flow (`/pair qr` or `/pair`, then `/pair approve` in Telegram).
- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt.
- Chat + Talk surfaces through the operator gateway session.
- iOS node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications.
- Authenticated background `node.presence.alive` beacons that update gateway last-seen metadata when the app moves between foreground and background, without treating suspended sockets as connected.
- Share extension deep-link forwarding into the connected gateway session.
## Computer Use Relationship
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
## Location Automation Use Case (Testing)
Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism.
- Product intent:
- movement-aware automations driven by iOS location events
- example: arrival/exit geofence, significant movement, visit detection
- Non-goal:
- continuous GPS polling just to keep the app alive
Test path to include in QA runs:
1. Enable location permission in app:
- set `Always` permission
- verify background location capability is enabled in the build profile
2. Background the app and trigger movement:
- walk/drive enough for a significant location update, or cross a configured geofence
3. Validate gateway side effects:
- node reconnect/wake if needed
- expected location/movement event arrives at gateway
- automation trigger executes once (no duplicate storm)
4. Validate resource impact:
- no sustained high thermal state
- no excessive background battery drain over a short observation window
Pass criteria:
- movement events are delivered reliably enough for automation UX
- no location-driven reconnect spam loops
- app remains stable after repeated background/foreground transitions
## Known Issues / Limitations / Problems
- Foreground-first: iOS can suspend sockets in background; reconnect recovery is still being tuned.
- Background command limits are strict: `canvas.*`, `camera.*`, `screen.*`, and `talk.*` are blocked when backgrounded.
- Background location requires `Always` location permission.
- Pairing/auth errors intentionally pause reconnect loops until a human fixes auth/pairing state.
- Voice Wake and Talk contend for the same microphone; Talk suppresses wake capture while active.
- APNs reliability depends on local signing/provisioning/topic alignment.
- Expect rough UX edges and occasional reconnect churn during active development.
## Current In-Progress Workstream
Automatic wake/reconnect hardening:
- improve wake/resume behavior across scene transitions
- reduce dead-socket states after background -> foreground
- tighten node/operator session reconnect coordination
- reduce manual recovery steps after transient network failures
## Debugging Checklist
1. Confirm build/signing baseline:
- regenerate project (`xcodegen generate`)
- verify selected team + bundle IDs
2. In app `Settings -> Gateway`:
- confirm status text, server, and remote address
- verify whether status shows pairing/auth gating
3. If pairing is required:
- run `/pair approve` from Telegram, then reconnect
4. If discovery is flaky:
- enable `Discovery Debug Logs`
- inspect `Settings -> Gateway -> Discovery Logs`
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`
- `GatewayDiag`
- `APNs registration failed`
7. Validate background expectations:
- repro in foreground first
- then test background transitions and confirm reconnect on return

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,96 @@
Inter
Copyright 2016 The Inter Project Authors
Source: https://github.com/google/fonts/tree/main/ofl/inter
Version: Google Fonts variable release
License: SIL Open Font License 1.1
Bundled files:
- Inter[opsz,wght].ttf
- Inter-Italic[opsz,wght].ttf
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,97 @@
JetBrains Mono
Copyright 2020 The JetBrains Mono Project Authors
Source: https://github.com/JetBrains/JetBrainsMono
Version: 2.304
License: SIL Open Font License 1.1
Bundled files:
- JetBrainsMono-Regular.ttf
- JetBrainsMono-Medium.ttf
- JetBrainsMono-SemiBold.ttf
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,95 @@
Red Hat Display
Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont)
Source: https://github.com/google/fonts/tree/main/ofl/redhatdisplay
Version: Google Fonts variable release
License: SIL Open Font License 1.1
Bundled files:
- RedHatDisplay[wght].ttf
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Noah Chalifour
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,58 @@
BSD 3-Clause License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Google WebRTC
Copyright (c) 2011, The WebRTC project authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
<?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>OpenClaw Share</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>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_ACTIVE_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_ACTIVE_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,567 @@
import Foundation
import OpenClawKit
import os
import UIKit
import UniformTypeIdentifiers
final class ShareViewController: UIViewController {
private struct ShareAttachment: Codable {
var type: String
var mimeType: String
var fileName: String
var content: String
}
private struct ExtractedShareContent {
var payload: SharedContentPayload
var attachments: [ShareAttachment]
}
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
private var statusLabel: UILabel?
private let draftTextView = UITextView()
private let sendButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private var didPrepareDraft = false
private var isSending = false
private var pendingAttachments: [ShareAttachment] = []
override func viewDidLoad() {
super.viewDidLoad()
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
self.setupUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !self.didPrepareDraft else { return }
self.didPrepareDraft = true
Task { await self.prepareDraft() }
}
private func setupUI() {
self.view.backgroundColor = .systemBackground
self.draftTextView.translatesAutoresizingMaskIntoConstraints = false
self.draftTextView.font = .preferredFont(forTextStyle: .body)
self.draftTextView.backgroundColor = UIColor.secondarySystemBackground
self.draftTextView.layer.cornerRadius = 10
self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
self.sendButton.translatesAutoresizingMaskIntoConstraints = false
self.sendButton.setTitle(
NSLocalizedString("Send to OpenClaw", comment: "Share extension send action"),
for: .normal)
self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside)
self.sendButton.isEnabled = false
self.cancelButton.translatesAutoresizingMaskIntoConstraints = false
self.cancelButton.setTitle(NSLocalizedString("Cancel", comment: "Share extension cancel action"), for: .normal)
self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton])
buttons.translatesAutoresizingMaskIntoConstraints = false
buttons.axis = .horizontal
buttons.alignment = .fill
buttons.distribution = .fillEqually
buttons.spacing = 12
self.view.addSubview(self.draftTextView)
self.view.addSubview(buttons)
NSLayoutConstraint.activate([
self.draftTextView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 14),
self.draftTextView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14),
self.draftTextView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14),
self.draftTextView.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -12),
buttons.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14),
buttons.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14),
buttons.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, constant: -8),
buttons.heightAnchor.constraint(equalToConstant: 44),
])
}
private func prepareDraft() async {
let traceId = UUID().uuidString
ShareGatewayRelaySettings.saveLastEvent("Share opened.")
self.showStatus(NSLocalizedString("Preparing share…", comment: "Share extension preparation status"))
self.logger.info("share begin trace=\(traceId, privacy: .public)")
let extracted = await self.extractSharedContent()
let payload = extracted.payload
self.pendingAttachments = extracted.attachments
self.logger.info("share payload trace=\(traceId, privacy: .public)")
self.logger.info(
"share payload title=\(payload.title?.count ?? 0) text=\(payload.text?.count ?? 0)")
self.logger.info(
"share attachments hasURL=\(payload.url != nil) images=\(self.pendingAttachments.count)")
let message = self.composeDraft(from: payload)
await MainActor.run {
self.draftTextView.text = message
self.sendButton.isEnabled = true
self.draftTextView.becomeFirstResponder()
}
if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.")
self.showStatus(NSLocalizedString(
"Add a message, then tap Send.",
comment: "Share extension empty draft guidance"))
} else {
ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.")
self.showStatus(NSLocalizedString("Edit text, then tap Send.", comment: "Share extension draft guidance"))
}
}
@objc
private func handleSendTap() {
guard !self.isSending else { return }
Task { await self.sendCurrentDraft() }
}
@objc
private func handleCancelTap() {
self.extensionContext?.completeRequest(returningItems: nil)
}
private func sendCurrentDraft() async {
let message = await MainActor.run { self.draftTextView.text ?? "" }
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.")
self.showStatus(NSLocalizedString("Message is empty.", comment: "Share extension empty message status"))
return
}
await MainActor.run {
self.isSending = true
self.sendButton.isEnabled = false
self.cancelButton.isEnabled = false
}
self.showStatus(NSLocalizedString("Sending to OpenClaw gateway…", comment: "Share extension sending status"))
ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…")
do {
try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments)
ShareGatewayRelaySettings.saveLastEvent(
"Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).")
self.showStatus(NSLocalizedString("Sent to OpenClaw.", comment: "Share extension success status"))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
self.extensionContext?.completeRequest(returningItems: nil)
}
} catch {
self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)")
ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)")
self.showStatus(
String(
format: NSLocalizedString("Send failed: %@", comment: "Share extension failure status"),
error.localizedDescription))
await MainActor.run {
self.isSending = false
self.sendButton.isEnabled = true
self.cancelButton.isEnabled = true
}
}
}
private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws {
guard let config = ShareGatewayRelaySettings.loadConfig() else {
throw NSError(
domain: "OpenClawShare",
code: 10,
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"OpenClaw is not connected to a gateway yet.",
comment: "Share extension missing gateway error"),
])
}
guard let url = URL(string: config.gatewayURLString) else {
throw NSError(
domain: "OpenClawShare",
code: 11,
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"Invalid saved gateway URL.",
comment: "Share extension invalid gateway error"),
])
}
let gateway = GatewayNodeSession()
defer {
Task { await gateway.disconnect() }
}
let makeOptions: (String) -> GatewayConnectOptions = { clientId in
GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
}
do {
try await gateway.connect(
url: url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: makeOptions("openclaw-ios"),
sessionBox: nil,
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "share extension does not support node invoke"))
})
} catch {
let expectsLegacyClientId = self.shouldRetryWithLegacyClientId(error)
guard expectsLegacyClientId else { throw error }
try await gateway.connect(
url: url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: makeOptions("moltbot-ios"),
sessionBox: nil,
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "share extension does not support node invoke"))
})
}
struct AgentRequestPayload: Codable {
var message: String
var sessionKey: String?
var thinking: String
var deliver: Bool
var attachments: [ShareAttachment]?
var receipt: Bool
var receiptText: String?
var to: String?
var channel: String?
var timeoutSeconds: Int?
var key: String?
}
let deliveryChannel = config.deliveryChannel?.trimmingCharacters(in: .whitespacesAndNewlines)
let deliveryTo = config.deliveryTo?.trimmingCharacters(in: .whitespacesAndNewlines)
let canDeliverToRoute = (deliveryChannel?.isEmpty == false) && (deliveryTo?.isEmpty == false)
let params = AgentRequestPayload(
message: message,
sessionKey: config.sessionKey,
thinking: "low",
deliver: canDeliverToRoute,
attachments: attachments.isEmpty ? nil : attachments,
receipt: canDeliverToRoute,
receiptText: canDeliverToRoute ? "Just received your iOS share + request, working on it." : nil,
to: canDeliverToRoute ? deliveryTo : nil,
channel: canDeliverToRoute ? deliveryChannel : nil,
timeoutSeconds: nil,
key: UUID().uuidString)
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw NSError(
domain: "OpenClawShare",
code: 12,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."])
}
struct NodeEventParams: Codable {
var event: String
var payloadJSON: String
}
let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json))
guard let nodeEventParams = String(data: eventData, encoding: .utf8) else {
throw NSError(
domain: "OpenClawShare",
code: 13,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."])
}
_ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25)
}
private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool {
if let gatewayError = error as? GatewayResponseError {
let code = gatewayError.code.lowercased()
let message = gatewayError.message.lowercased()
let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? ""
let mentionsClientIdPath =
message.contains("/client/id") || message.contains("client id")
|| pathValue.contains("/client/id")
let isInvalidConnectParams =
(code.contains("invalid") && code.contains("connect"))
|| message.contains("invalid connect params")
if isInvalidConnectParams, mentionsClientIdPath {
return true
}
}
let text = error.localizedDescription.lowercased()
return text.contains("invalid connect params")
&& (text.contains("/client/id") || text.contains("client id"))
}
private func showStatus(_ text: String) {
DispatchQueue.main.async {
let label: UILabel
if let existing = self.statusLabel {
label = existing
} else {
let newLabel = UILabel()
newLabel.translatesAutoresizingMaskIntoConstraints = false
newLabel.numberOfLines = 0
newLabel.textAlignment = .center
newLabel.font = .preferredFont(forTextStyle: .body)
newLabel.textColor = .label
newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92)
newLabel.layer.cornerRadius = 12
newLabel.clipsToBounds = true
newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14)
self.view.addSubview(newLabel)
NSLayoutConstraint.activate([
newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18),
newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18),
newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10),
])
self.statusLabel = newLabel
label = newLabel
}
label.text = " \(text) "
}
}
private func composeDraft(from payload: SharedContentPayload) -> String {
var lines: [String] = []
let title = self.sanitizeDraftFragment(payload.title)
let text = self.sanitizeDraftFragment(payload.text)
let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if let title, !title.isEmpty { lines.append(title) }
if let text, !text.isEmpty { lines.append(text) }
if !url.isEmpty { lines.append(url) }
return lines.joined(separator: "\n\n")
}
private func sanitizeDraftFragment(_ raw: String?) -> String? {
guard let raw else { return nil }
let banned = [
"shared from ios.",
"text:",
"shared attachment(s):",
"please help me with this.",
"please help me with this.w",
]
let cleanedLines = raw
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { line in
guard !line.isEmpty else { return false }
let lowered = line.lowercased()
return !banned.contains { lowered == $0 || lowered.hasPrefix($0) }
}
let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? nil : cleaned
}
private func extractSharedContent() async -> ExtractedShareContent {
guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else {
return ExtractedShareContent(
payload: SharedContentPayload(title: nil, url: nil, text: nil),
attachments: [])
}
var title: String?
var sharedURL: URL?
var sharedText: String?
var imageCount = 0
var videoCount = 0
var fileCount = 0
var unknownCount = 0
var attachments: [ShareAttachment] = []
let maxImageAttachments = 3
for item in items {
if title == nil {
title = item.attributedTitle?.string ?? item.attributedContentText?.string
}
for provider in item.attachments ?? [] {
if sharedURL == nil {
sharedURL = await self.loadURL(from: provider)
}
if sharedText == nil {
sharedText = await self.loadText(from: provider)
}
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
imageCount += 1
if attachments.count < maxImageAttachments,
let attachment = await self.loadImageAttachment(from: provider, index: attachments.count)
{
attachments.append(attachment)
}
} else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
videoCount += 1
} else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
fileCount += 1
} else {
unknownCount += 1
}
}
}
_ = imageCount
_ = videoCount
_ = fileCount
_ = unknownCount
return ExtractedShareContent(
payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText),
attachments: attachments)
}
private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? {
let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier
guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else {
return nil
}
let maxBytes = 5_000_000
guard let image = UIImage(data: rawData),
let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes)
else {
return nil
}
return ShareAttachment(
type: "image",
mimeType: "image/jpeg",
fileName: "shared-image-\(index + 1).jpg",
content: data.base64EncodedString())
}
private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? {
for identifier in provider.registeredTypeIdentifiers {
guard let utType = UTType(identifier) else { continue }
if utType.conforms(to: .image) {
return identifier
}
}
return nil
}
private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? {
var quality: CGFloat = 0.9
while quality >= 0.4 {
if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes {
return data
}
quality -= 0.1
}
guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil }
if fallback.count <= maxBytes { return fallback }
return nil
}
private func loadURL(from provider: NSItemProvider) async -> URL? {
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
if let url = await self.loadURLValue(
from: provider,
typeIdentifier: UTType.url.identifier)
{
return url
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier),
let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
url.scheme != nil
{
return url
}
}
return nil
}
private func loadText(from provider: NSItemProvider) async -> String? {
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) {
return text
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) {
return url.absoluteString
}
}
return nil
}
private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? {
await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
if let url = item as? URL {
continuation.resume(returning: url)
return
}
if let str = item as? String, let url = URL(string: str) {
continuation.resume(returning: url)
return
}
if let ns = item as? NSString, let url = URL(string: ns as String) {
continuation.resume(returning: url)
return
}
continuation.resume(returning: nil)
}
}
}
private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? {
await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
if let text = item as? String {
continuation.resume(returning: text)
return
}
if let text = item as? NSString {
continuation.resume(returning: text as String)
return
}
if let text = item as? NSAttributedString {
continuation.resume(returning: text.string)
return
}
continuation.resume(returning: nil)
}
}
}
private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? {
await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
continuation.resume(returning: data)
}
}
}
}

View File

@@ -0,0 +1 @@
"CFBundleDisplayName" = "Dela till OpenClaw";

34
apps/ios/Signing.xcconfig Normal file
View File

@@ -0,0 +1,34 @@
// Default signing values for shared/repo builds.
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
#include "build/Version.xcconfig"
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.
#include? ".local-signing.xcconfig"
#include? "LocalSigning.xcconfig"
// Debug products install alongside release products and keep their app-group data separate.
OPENCLAW_DEBUG_APP_BUNDLE_ID = $(OPENCLAW_APP_BUNDLE_ID).debug
OPENCLAW_DEBUG_SHARE_BUNDLE_ID = $(OPENCLAW_DEBUG_APP_BUNDLE_ID).share
OPENCLAW_DEBUG_ACTIVITY_WIDGET_BUNDLE_ID = $(OPENCLAW_DEBUG_APP_BUNDLE_ID).activitywidget
OPENCLAW_DEBUG_WATCH_APP_BUNDLE_ID = $(OPENCLAW_DEBUG_APP_BUNDLE_ID).watchkitapp
OPENCLAW_DEBUG_APP_GROUP_ID = $(OPENCLAW_APP_GROUP_ID).debug

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,143 @@
import EventKit
import Foundation
import OpenClawKit
final class CalendarService: CalendarServicing {
private let eventAuthorizationStatus: @Sendable () -> EKAuthorizationStatus
init(
eventAuthorizationStatus: @escaping @Sendable () -> EKAuthorizationStatus = {
EKEventStore.authorizationStatus(for: .event)
})
{
self.eventAuthorizationStatus = eventAuthorizationStatus
}
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
let status = self.eventAuthorizationStatus()
guard EventKitAuthorization.allowsRead(status: status) else {
throw NSError(domain: "Calendar", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
let store = EKEventStore()
let (start, end) = Self.resolveRange(
startISO: params.startISO,
endISO: params.endISO)
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
let events = store.events(matching: predicate)
let limit = max(1, min(params.limit ?? 50, 500))
let selected = Array(events.prefix(limit))
let formatter = ISO8601DateFormatter()
let payload = selected.map { event in
OpenClawCalendarEventPayload(
identifier: event.eventIdentifier ?? UUID().uuidString,
title: event.title ?? "(untitled)",
startISO: formatter.string(from: event.startDate),
endISO: formatter.string(from: event.endDate),
isAllDay: event.isAllDay,
location: event.location,
calendarTitle: event.calendar.title)
}
return OpenClawCalendarEventsPayload(events: payload)
}
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
let status = self.eventAuthorizationStatus()
guard EventKitAuthorization.allowsWrite(status: status) else {
throw NSError(domain: "Calendar", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
let store = EKEventStore()
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw NSError(domain: "Calendar", code: 3, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required",
])
}
let formatter = ISO8601DateFormatter()
guard let start = formatter.date(from: params.startISO) else {
throw NSError(domain: "Calendar", code: 4, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required",
])
}
guard let end = formatter.date(from: params.endISO) else {
throw NSError(domain: "Calendar", code: 5, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required",
])
}
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = start
event.endDate = end
event.isAllDay = params.isAllDay ?? false
if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
event.location = location
}
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
event.notes = notes
}
event.calendar = try Self.resolveCalendar(
store: store,
calendarId: params.calendarId,
calendarTitle: params.calendarTitle)
try store.save(event, span: .thisEvent)
let payload = OpenClawCalendarEventPayload(
identifier: event.eventIdentifier ?? UUID().uuidString,
title: event.title ?? title,
startISO: formatter.string(from: event.startDate),
endISO: formatter.string(from: event.endDate),
isAllDay: event.isAllDay,
location: event.location,
calendarTitle: event.calendar.title)
return OpenClawCalendarAddPayload(event: payload)
}
private static func resolveCalendar(
store: EKEventStore,
calendarId: String?,
calendarTitle: String?) throws -> EKCalendar
{
if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
let calendar = store.calendar(withIdentifier: id)
{
return calendar
}
if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
if let calendar = store.calendars(for: .event).first(where: {
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
}) {
return calendar
}
throw NSError(domain: "Calendar", code: 6, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)",
])
}
if let fallback = store.defaultCalendarForNewEvents {
return fallback
}
throw NSError(domain: "Calendar", code: 7, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar",
])
}
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
let formatter = ISO8601DateFormatter()
let start = startISO.flatMap { formatter.date(from: $0) } ?? Date()
let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600)
return (start, end)
}
}

View File

@@ -0,0 +1,353 @@
import AVFoundation
import Foundation
import OpenClawKit
import os
actor CameraController {
struct CameraDeviceInfo: Codable {
var id: String
var name: String
var position: String
var deviceType: String
}
enum CameraError: LocalizedError {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)
case invalidParams(String)
case captureFailed(String)
case exportFailed(String)
var errorDescription: String? {
switch self {
case .cameraUnavailable:
"Camera unavailable"
case .microphoneUnavailable:
"Microphone unavailable"
case let .permissionDenied(kind):
"\(kind) permission denied"
case let .invalidParams(msg):
msg
case let .captureFailed(msg):
msg
case let .exportFailed(msg):
msg
}
}
}
func snap(params: OpenClawCameraSnapParams) async throws -> (
format: String,
base64: String,
width: Int,
height: Int)
{
let facing = params.facing ?? .front
let format = params.format ?? .jpg
// Default to a reasonable max width to keep gateway payload sizes manageable.
// If you need the full-res photo, explicitly request a larger maxWidth.
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(params.quality)
let delayMs = max(0, params.delayMs ?? 0)
try await self.ensureAccess(for: .video)
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: { setupError in
CameraError.captureFailed(setupError.localizedDescription)
})
let session = prepared.session
let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
await CameraCapturePipelineSupport.warmUpCaptureSession()
await Self.sleepDelayMs(delayMs)
let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
PhotoCaptureDelegate(continuation)
}
let res = try PhotoCapture.transcodeJPEGForGateway(
rawData: rawData,
maxWidthPx: maxWidth,
quality: quality)
return (
format: format.rawValue,
base64: res.data.base64EncodedString(),
width: res.widthPx,
height: res.heightPx)
}
func clip(params: OpenClawCameraClipParams) async throws -> (
format: String,
base64: String,
durationMs: Int,
hasAudio: Bool)
{
let facing = params.facing ?? .front
let durationMs = Self.clampDurationMs(params.durationMs)
let includeAudio = params.includeAudio ?? true
let format = params.format ?? .mp4
try await self.ensureAccess(for: .video)
if includeAudio {
try await self.ensureAccess(for: .audio)
}
let movURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
defer {
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
}
let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
includeAudio: includeAudio,
durationMs: durationMs,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError,
operation: { output in
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont)
delegate = d
output.startRecording(to: movURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
return try Data(contentsOf: mp4URL)
})
return (
format: format.rawValue,
base64: data.base64EncodedString(),
durationMs: durationMs,
hasAudio: includeAudio)
}
func listDevices() -> [CameraDeviceInfo] {
Self.discoverVideoDevices().map { device in
CameraDeviceInfo(
id: device.uniqueID,
name: device.localizedName,
position: Self.positionLabel(device.position),
deviceType: device.deviceType.rawValue)
}
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
private nonisolated static func pickCamera(
facing: OpenClawCameraFacing,
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
if let match = discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
return match
}
}
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
return device
}
// Fall back to any default camera (e.g. simulator / unusual device configurations).
return AVCaptureDevice.default(for: .video)
}
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
CameraCapturePipelineSupport.mapMovieSetupError(
setupError,
microphoneUnavailableError: .microphoneUnavailable,
captureFailed: { .captureFailed($0) })
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
CameraCapturePipelineSupport.positionLabel(position)
}
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
}
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000
// Keep clips short by default; avoid huge base64 payloads on the gateway.
return min(60000, max(250, v))
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
throw CameraError.exportFailed("Failed to create export session")
}
exporter.shouldOptimizeForNetworkUse = true
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
do {
try await exporter.export(to: outputURL, as: .mp4)
return
} catch {
throw CameraError.exportFailed(error.localizedDescription)
}
} else {
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
exporter.exportAsynchronously {
cont.resume(returning: ())
}
}
switch exporter.status {
case .completed:
return
case .failed:
throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed")
case .cancelled:
throw CameraError.exportFailed("export cancelled")
default:
throw CameraError.exportFailed("export did not complete")
}
}
}
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return }
let maxDelayMs = 10 * 1000
let ns = UInt64(min(delayMs, maxDelayMs)) * UInt64(NSEC_PER_MSEC)
try? await Task.sleep(nanoseconds: ns)
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private let continuation: CheckedContinuation<Data, Error>
private let resumed = OSAllocatedUnfairLock(initialState: false)
init(_ continuation: CheckedContinuation<Data, Error>) {
self.continuation = continuation
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?)
{
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
if let error {
self.continuation.resume(throwing: error)
return
}
guard let data = photo.fileDataRepresentation() else {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 1, userInfo: [
NSLocalizedDescriptionKey: "photo data missing",
]))
return
}
if data.isEmpty {
self.continuation.resume(
throwing: NSError(domain: "Camera", code: 2, userInfo: [
NSLocalizedDescriptionKey: "photo data empty",
]))
return
}
self.continuation.resume(returning: data)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?)
{
guard let error else { return }
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
self.continuation.resume(throwing: error)
}
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
private let continuation: CheckedContinuation<URL, Error>
private let resumed = OSAllocatedUnfairLock(initialState: false)
init(_ continuation: CheckedContinuation<URL, Error>) {
self.continuation = continuation
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?)
{
let alreadyResumed = self.resumed.withLock { old in
let was = old
old = true
return was
}
guard !alreadyResumed else { return }
if let error {
let ns = error as NSError
if ns.domain == AVFoundationErrorDomain,
ns.code == AVError.maximumDurationReached.rawValue
{
self.continuation.resume(returning: outputFileURL)
return
}
self.continuation.resume(throwing: error)
return
}
self.continuation.resume(returning: outputFileURL)
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
import OpenClawKit
@MainActor
final class NodeCapabilityRouter {
enum RouterError: Error {
case unknownCommand
case handlerUnavailable
}
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
private let handlers: [String: Handler]
init(handlers: [String: Handler]) {
self.handlers = handlers
}
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard let handler = handlers[request.command] else {
throw RouterError.unknownCommand
}
return try await handler(request)
}
}

View File

@@ -0,0 +1,432 @@
import Foundation
import OpenClawChatUI
import OpenClawProtocol
enum AppleReviewDemoMode {
static let setupCode = "APPLE-REVIEW-DEMO"
static let gatewayName = "Apple Review Demo Gateway"
static let gatewayAddress = "Local demo mode"
static let gatewayID = "apple-review-demo"
static func isSetupCode(_ value: String) -> Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines)
.localizedCaseInsensitiveCompare(self.setupCode) == .orderedSame
}
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",
identity: ["emoji": AnyCodable("OC")],
workspace: "Apple Review Demo",
model: ["provider": AnyCodable("demo"), "model": AnyCodable("local-demo")],
agentruntime: ["kind": AnyCodable("local")],
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: ProcessInfo.processInfo.arguments.contains("--openclaw-empty-chat-fixture")
? []
: ["Ready when you are. I can check a project, coordinate an agent, or prepare the next step."],
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)
}
func createSession(
key: String,
label _: String?,
parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse
{
try await self.store.createSession(key: key)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.store.history(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: self.fixture.modelID,
name: self.fixture.modelName,
provider: self.fixture.modelProvider,
contextWindow: 128_000),
]
}
func sendMessage(
sessionKey: String,
message: String,
thinking _: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.store.sendMessage(
sessionKey: sessionKey,
message: message,
runId: idempotencyKey)
}
func abortRun(sessionKey _: String, runId _: String) async throws {}
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
try await self.store.sessions()
}
func setSessionModel(sessionKey _: String, model _: String?) async throws {}
func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool {
true
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
continuation.yield(.health(ok: true))
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
func resetSession(sessionKey _: String) async throws {
await self.store.reset()
}
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 var messages: [OpenClawChatMessage]
init(fixture: LocalChatFixture) {
self.fixture = fixture
self.messages = Self.seedMessages(fixture: fixture)
}
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
try Self.decode(
CreateSessionPayload(ok: true, key: key, sessionId: "\(self.fixture.sessionIDPrefix)-\(key)"),
as: OpenClawChatCreateSessionResponse.self)
}
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey, fallback: self.fixture.sessionKey)
return try Self.decode(
HistoryPayload(
sessionKey: normalizedSessionKey,
sessionId: "\(self.fixture.sessionIDPrefix)-\(normalizedSessionKey)",
messages: self.messages,
thinkingLevel: "auto"),
as: OpenClawChatHistoryPayload.self)
}
func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse {
let now = Date().timeIntervalSince1970 * 1000
self.messages.append(
Self.message(
role: "user",
text: message,
timestamp: now,
idempotencyKey: "\(runId):user"))
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\""
self.messages.append(
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.
""",
timestamp: now + 1))
return try Self.decode(
SendPayload(runId: runId, status: "ok"),
as: OpenClawChatSendResponse.self)
}
func sessions() throws -> OpenClawChatSessionsListResponse {
let entry = OpenClawChatSessionEntry(
key: self.fixture.sessionKey,
kind: "chat",
displayName: self.fixture.displayName,
surface: "ios",
subject: self.fixture.subject,
room: nil,
space: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
sessionId: "\(self.fixture.sessionIDPrefix)-\(self.fixture.sessionKey)",
systemSent: true,
abortedLastRun: false,
thinkingLevel: "auto",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
contextTokens: 128_000,
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingDefault: "auto")
return OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: self.fixture.modelProvider,
model: self.fixture.modelID,
contextTokens: 128_000,
thinkingLevels: Self.thinkingLevels,
thinkingOptions: Self.thinkingOptions,
thinkingDefault: "auto",
mainSessionKey: self.fixture.sessionKey),
sessions: [entry])
}
func reset() {
self.messages = Self.seedMessages(fixture: self.fixture)
}
private static var thinkingOptions: [String] {
["auto", "low", "medium", "high"]
}
private static var thinkingLevels: [OpenClawChatThinkingLevelOption] {
[
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
OpenClawChatThinkingLevelOption(id: "high", label: "High"),
]
}
private static func seedMessages(fixture: LocalChatFixture) -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return fixture.seedMessages.enumerated().map { index, text in
self.message(role: "assistant", text: text, timestamp: now + Double(index))
}
}
private static func message(
role: String,
text: String,
timestamp: Double,
idempotencyKey: String? = nil) -> OpenClawChatMessage
{
OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp,
idempotencyKey: idempotencyKey)
}
private static func normalizedSessionKey(_ value: String, fallback: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
}
private struct HistoryPayload: Encodable {
var sessionKey: String
var sessionId: String?
var messages: [OpenClawChatMessage]?
var thinkingLevel: String?
}
private struct SendPayload: Encodable {
var runId: String
var status: String
}
private struct CreateSessionPayload: Encodable {
var ok: Bool?
var key: String
var sessionId: String?
}
}

View File

@@ -0,0 +1,389 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
static let defaultChatSendTimeoutMs = 30000
static let compactionRequestTimeoutSeconds = 0
private let gateway: GatewayNodeSession
private struct CreateSessionParams: Codable {
var key: String
var label: String?
var parentSessionKey: String?
}
private struct RunParams: Codable {
var sessionKey: String
var runId: String
}
private struct ListSessionsParams: Codable {
var includeGlobal: Bool
var includeUnknown: Bool
var limit: Int?
}
private struct SessionKeyParams: Codable {
var key: String
}
private struct ChatSendParams: Codable {
var sessionKey: String
var message: String
var thinking: String
var attachments: [OpenClawChatAttachmentPayload]?
var timeoutMs: Int
var idempotencyKey: String
}
private struct CommandsListRequestParams: Codable {
var scope: String
var includeArgs: Bool
var agentId: String?
}
private struct AgentWaitParams: Codable {
var runId: String
var timeoutMs: Int
}
private struct AgentWaitResponse: Codable {
var runId: String?
var status: String?
var error: String?
}
struct AgentWaitCompletion: Equatable {
var runId: String
var status: String
var completed: Bool
}
static func isAgentWaitCompletionStatus(_ status: String) -> Bool {
switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "ok", "completed", "success", "succeeded":
true
default:
false
}
}
init(gateway: GatewayNodeSession) {
self.gateway = gateway
}
static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int {
max(1, Int(ceil(Double(timeoutMs) / 1000.0)) + 5)
}
static func makeListSessionsParamsJSON(limit: Int?) throws -> String {
try self.encodeParams(ListSessionsParams(includeGlobal: true, includeUnknown: false, limit: limit))
}
static func makeChatSendParamsJSON(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) throws -> String
{
let params = ChatSendParams(
sessionKey: sessionKey,
message: message,
thinking: thinking,
attachments: attachments.isEmpty ? nil : attachments,
timeoutMs: self.defaultChatSendTimeoutMs,
idempotencyKey: idempotencyKey)
return try self.encodeParams(params)
}
static func makeCommandsListParamsJSON(sessionKey: String? = nil) throws -> String {
try self.encodeParams(CommandsListRequestParams(
scope: "text",
includeArgs: true,
agentId: self.agentID(fromSessionKey: sessionKey)))
}
static func agentID(fromSessionKey sessionKey: String?) -> String? {
let parts = (sessionKey ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil }
let agentID = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return agentID.isEmpty ? nil : agentID
}
static func decodeAgentWaitCompletion(_ data: Data, fallbackRunId: String) throws -> AgentWaitCompletion {
let decoded = try JSONDecoder().decode(AgentWaitResponse.self, from: data)
let status = (decoded.status ?? "unknown").lowercased()
return AgentWaitCompletion(
runId: decoded.runId ?? fallbackRunId,
status: status,
completed: self.isAgentWaitCompletionStatus(status))
}
private static func makeCreateSessionParamsJSON(
key: String,
label: String?,
parentSessionKey: String?) throws -> String
{
let params = CreateSessionParams(
key: key,
label: label,
parentSessionKey: parentSessionKey)
return try self.encodeParams(params)
}
private static func makeRunParamsJSON(sessionKey: String, runId: String) throws -> String {
try self.encodeParams(RunParams(sessionKey: sessionKey, runId: runId))
}
private static func makeSessionKeyParamsJSON(_ sessionKey: String) throws -> String {
try self.encodeParams(SessionKeyParams(key: sessionKey))
}
private static func makeHistoryParamsJSON(sessionKey: String) throws -> String {
struct Params: Codable { var sessionKey: String }
return try self.encodeParams(Params(sessionKey: sessionKey))
}
private static func makeAgentWaitParamsJSON(runId: String, timeoutMs: Int) throws -> String {
try self.encodeParams(AgentWaitParams(runId: runId, timeoutMs: timeoutMs))
}
private static func encodeParams(_ params: some Encodable) throws -> String {
let data = try JSONEncoder().encode(params)
guard let json = String(bytes: data, encoding: .utf8) else {
throw EncodingError.invalidValue(
params,
EncodingError.Context(codingPath: [], debugDescription: "Encoded gateway params were not UTF-8"))
}
return json
}
func createSession(
key: String,
label: String?,
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
{
let json = try Self.makeCreateSessionParamsJSON(
key: key,
label: label,
parentSessionKey: parentSessionKey)
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
}
func abortRun(sessionKey: String, runId: String) async throws {
let json = try Self.makeRunParamsJSON(sessionKey: sessionKey, runId: runId)
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
}
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
let json = try Self.makeListSessionsParamsJSON(limit: limit)
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
_ = try await self.gateway.request(
method: "sessions.messages.subscribe",
paramsJSON: json,
timeoutSeconds: 10)
}
func resetSession(sessionKey: String) async throws {
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
}
func compactSession(sessionKey: String) async throws {
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
let response = try await self.gateway.request(
method: "sessions.compact",
paramsJSON: json,
timeoutSeconds: Self.compactionRequestTimeoutSeconds)
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
}
var supportsSlashCommandCatalog: Bool {
true
}
func listCommands(sessionKey: String) async throws -> [OpenClawChatCommandChoice] {
let json = try Self.makeCommandsListParamsJSON(sessionKey: sessionKey)
let res = try await self.gateway.request(method: "commands.list", paramsJSON: json, timeoutSeconds: 15)
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: res)
return decoded.commands.map(Self.mapCommandChoice)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
let startLogMessage =
"chat.send start sessionKey=\(sessionKey) "
+ "len=\(message.count) attachments=\(attachments.count)"
Self.logger.info(
"\(startLogMessage, privacy: .public)")
GatewayDiagnostics.log(startLogMessage)
let json = try Self.makeChatSendParamsJSON(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
return decoded
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
throw error
}
}
private static func mapCommandChoice(_ entry: CommandEntry) -> OpenClawChatCommandChoice {
let sourceValue = (entry.source.value as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let source: OpenClawChatCommandChoice.Source = switch sourceValue {
case "native":
.command
case "skill":
.skill
case "plugin":
.plugin
default:
.unknown
}
let aliases = (entry.textaliases ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let id = [
source.rawValue,
entry.name.trimmingCharacters(in: .whitespacesAndNewlines),
aliases.first ?? "",
].joined(separator: ":")
return OpenClawChatCommandChoice(
id: id,
name: entry.name,
textAliases: aliases,
description: entry.description,
source: source,
acceptsArgs: entry.acceptsargs)
}
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !runId.isEmpty else { return false }
do {
let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs)
let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs)
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
let res = try await self.gateway.request(
method: "agent.wait",
paramsJSON: json,
timeoutSeconds: requestTimeoutSeconds)
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
if !completion.completed {
Self.logger.warning(
"agent.wait status \(completion.status, privacy: .public) runId=\(runId, privacy: .public)")
}
return completion.completed
} catch {
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
return false
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
let stream = await self.gateway.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
if let mapped = Self.mapEventFrame(evt) {
continuation.yield(mapped)
}
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
static func mapEventFrame(_ evt: EventFrame) -> OpenClawChatTransportEvent? {
switch evt.event {
case "tick":
return .tick
case "seqGap":
return .seqGap
case "health":
guard let payload = evt.payload else { return nil }
let ok = (try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawGatewayHealthOK.self))?.ok ?? true
return .health(ok: ok)
case "chat":
guard let payload = evt.payload else { return nil }
guard let chatPayload = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawChatEventPayload.self)
else {
return nil
}
return .chat(chatPayload)
case "session.message":
guard let payload = evt.payload else { return nil }
guard let message = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawSessionMessageEventPayload.self)
else {
return nil
}
return .sessionMessage(message)
case "agent":
guard let payload = evt.payload else { return nil }
guard let agentPayload = try? GatewayPayloadDecoding.decode(
payload,
as: OpenClawAgentEventPayload.self)
else {
return nil
}
return .agent(agentPayload)
default:
return nil
}
}
}

View File

@@ -0,0 +1,214 @@
import Contacts
import Foundation
import OpenClawKit
final class ContactsService: ContactsServicing {
private let authorizationStatus: @Sendable () -> CNAuthorizationStatus
init(
authorizationStatus: @escaping @Sendable () -> CNAuthorizationStatus = {
CNContactStore.authorizationStatus(for: .contacts)
})
{
self.authorizationStatus = authorizationStatus
}
private static var payloadKeys: [CNKeyDescriptor] {
[
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
// CNContactFormatter requires more keys than payload(from:) accesses directly.
// Fetch its declared key set; formatting a partial contact otherwise raises an
// Objective-C CNContactPropertyNotFetchedException and crashes the app.
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
]
}
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
let store = try self.authorizedStore()
let limit = max(1, min(params.limit ?? 25, 200))
var contacts: [CNContact] = []
if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
let predicate = CNContact.predicateForContacts(matchingName: query)
contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
} else {
let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
try store.enumerateContacts(with: request) { contact, stop in
contacts.append(contact)
if contacts.count >= limit {
stop.pointee = true
}
}
}
let sliced = Array(contacts.prefix(limit))
let payload = sliced.map { Self.payload(from: $0) }
return OpenClawContactsSearchPayload(contacts: payload)
}
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
let store = try self.authorizedStore()
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
let emails = Self.normalizeStrings(params.emails, lowercased: true)
let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
let hasOrg = !(organizationName ?? "").isEmpty
let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
guard hasName || hasOrg || hasDetails else {
throw NSError(domain: "Contacts", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
])
}
if !phoneNumbers.isEmpty || !emails.isEmpty {
if let existing = try Self.findExistingContact(
store: store,
phoneNumbers: phoneNumbers,
emails: emails)
{
return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
}
}
let contact = CNMutableContact()
contact.givenName = givenName ?? ""
contact.familyName = familyName ?? ""
contact.organizationName = organizationName ?? ""
if contact.givenName.isEmpty, contact.familyName.isEmpty, let displayName {
contact.givenName = displayName
}
contact.phoneNumbers = phoneNumbers.map {
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
}
contact.emailAddresses = emails.map {
CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
}
let save = CNSaveRequest()
save.add(contact, toContainerWithIdentifier: nil)
try store.execute(save)
let persisted: CNContact = if !contact.identifier.isEmpty {
try store.unifiedContact(
withIdentifier: contact.identifier,
keysToFetch: Self.payloadKeys)
} else {
contact
}
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
}
private static func ensureAuthorization(status: CNAuthorizationStatus) -> Bool {
switch status {
case .authorized, .limited:
return true
case .notDetermined:
return false
case .restricted, .denied:
return false
@unknown default:
return false
}
}
private func authorizedStore() throws -> CNContactStore {
let status = self.authorizationStatus()
let authorized = Self.ensureAuthorization(status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
return CNContactStore()
}
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
(values ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { lowercased ? $0.lowercased() : $0 }
}
private static func findExistingContact(
store: CNContactStore,
phoneNumbers: [String],
emails: [String]) throws -> CNContact?
{
if phoneNumbers.isEmpty, emails.isEmpty {
return nil
}
var matches: [CNContact] = []
for phone in phoneNumbers {
let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
matches.append(contentsOf: contacts)
}
for email in emails {
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
matches.append(contentsOf: contacts)
}
return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
}
private static func matchContacts(
contacts: [CNContact],
phoneNumbers: [String],
emails: [String]) -> CNContact?
{
let normalizedPhones = Set(phoneNumbers.map { self.normalizePhone($0) }.filter { !$0.isEmpty })
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
var seen = Set<String>()
for contact in contacts {
guard seen.insert(contact.identifier).inserted else { continue }
let contactPhones = Set(contact.phoneNumbers.map { self.normalizePhone($0.value.stringValue) })
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
return contact
}
if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
return contact
}
}
return nil
}
private static func normalizePhone(_ phone: String) -> String {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
let normalized = String(String.UnicodeScalarView(digits))
return normalized.isEmpty ? trimmed : normalized
}
private static func payload(from contact: CNContact) -> OpenClawContactPayload {
OpenClawContactPayload(
identifier: contact.identifier,
displayName: CNContactFormatter.string(from: contact, style: .fullName)
?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
givenName: contact.givenName,
familyName: contact.familyName,
organizationName: contact.organizationName,
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
emails: contact.emailAddresses.map { String($0.value) })
}
}

View File

@@ -0,0 +1,718 @@
import Foundation
import OpenClawKit
import SwiftUI
struct AgentProDreamingDestination: View {
@Environment(NodeAppModel.self) private var appModel
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let overviewLoading: Bool
let dreamingValue: String
let dreamingDetail: String
let dreamingColor: Color
let refresh: () async -> Void
@State private var selectedDreamDiaryDayID: String?
@State private var dreamActionBusy: DreamAction?
@State private var dreamActionStatusText: String?
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.detailSummaryCard(
icon: "moon",
title: "Dreaming",
value: self.dreamingValue,
detail: self.dreamingDetail,
color: self.dreamingColor)
self.dreamingTotalsCard
self.dreamingActionsCard
self.dreamDiaryCard
self.dreamingEntriesList(
title: "Promoted Entries",
entries: self.overview?.dreaming?.promotedEntries ?? [],
emptyTitle: "No promoted entries",
emptyDetail: "Dreaming has not promoted durable memory entries yet.")
self.dreamingEntriesList(
title: "Signal Entries",
entries: self.overview?.dreaming?.signalEntries ?? [],
emptyTitle: "No signal entries",
emptyDetail: "No recent recall, daily, grounded, or phase signals were reported.")
self.dreamingEntriesList(
title: "Short-Term Recall",
entries: self.overview?.dreaming?.shortTermEntries ?? [],
emptyTitle: "No short-term entries",
emptyDetail: "The short-term dreaming store is empty.")
self.dreamingPhasesCard
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.refreshable {
await self.refresh()
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Dreaming")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Dreaming",
subtitle: self.dreamingDetail,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.subheadMedium)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum DreamAction: String, CaseIterable, Identifiable {
case backfill
case repair
case dedupe
var id: Self {
self
}
var title: String {
switch self {
case .backfill: "Backfill"
case .repair: "Repair"
case .dedupe: "Dedupe"
}
}
var icon: String {
switch self {
case .backfill: "book.pages"
case .repair: "wrench.and.screwdriver"
case .dedupe: "square.stack.3d.down.right"
}
}
var method: String {
switch self {
case .backfill: "doctor.memory.backfillDreamDiary"
case .repair: "doctor.memory.repairDreamingArtifacts"
case .dedupe: "doctor.memory.dedupeDreamDiary"
}
}
}
private func detailSummaryCard(
icon: String,
title: String,
value: String,
detail: String,
color: Color) -> some View
{
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.headline)
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamingTotalsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Memory State")
.font(OpenClawType.headline)
Spacer()
ProValuePill(value: self.dreamingValue, color: self.dreamingColor)
}
HStack(spacing: 10) {
self.detailMetric(
label: "Short-term",
value: Self.compactNumber(self.overview?.dreaming?.shortTermCount ?? 0))
self.detailMetric(
label: "Signals",
value: Self.compactNumber(self.overview?.dreaming?.totalSignalCount ?? 0))
self.detailMetric(
label: "Promoted",
value: Self.compactNumber(self.overview?.dreaming?.promotedToday ?? 0))
}
if let storeError = self.normalized(self.overview?.dreaming?.storeError) {
Text(storeError)
.font(OpenClawType.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamingActionsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Maintenance")
.font(OpenClawType.headline)
Text("Refresh reads live state. Maintenance actions update the gateway diary/artifacts.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Button {
Task { await self.refresh() }
} label: {
Image(systemName: self.overviewLoading ? "hourglass" : "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.overviewLoading)
.accessibilityLabel("Refresh dreaming")
}
HStack(spacing: 8) {
ForEach(DreamAction.allCases) { action in
Button {
Task { await self.runDreamAction(action) }
} label: {
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
.font(OpenClawType.captionSemiBold)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!self.gatewayConnected || self.dreamActionBusy != nil)
}
}
if let dreamActionStatusText {
Text(dreamActionStatusText)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamDiaryCard: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Dream Diary")
ProCard(padding: 0) {
if let diary = self.overview?.dreamDiary {
if diary.found, let content = self.normalizedMultiline(diary.content) {
let days = Self.dreamDiaryDays(from: content)
let selectedDay = self.selectedDreamDiaryDay(from: days)
VStack(alignment: .leading, spacing: 12) {
HStack {
ProIconBadge(systemName: "book.pages", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 2) {
Text(diary.path)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Text(self.dreamDiaryUpdatedLabel(diary))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if !days.isEmpty {
self.dreamDiaryDayMenu(days: days, selectedDay: selectedDay)
}
}
if let selectedDay {
self.dreamDiaryDayView(selectedDay)
} else {
self.emptyDetailRow(
icon: "calendar.badge.exclamationmark",
title: "No day entries",
detail: "The diary is present, but it does not contain dated Dream Diary blocks.")
}
}
.padding(14)
} else {
self.emptyDetailRow(
icon: "book.closed",
title: diary.found ? "Dream diary is empty" : "No dream diary yet",
detail: diary.found
? "\(diary.path) exists but has no readable content."
: "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.")
.padding(14)
}
} else {
self.emptyDetailRow(
icon: "book.closed",
title: self.gatewayConnected ? "Diary unavailable" : "Dreaming unavailable",
detail: self.gatewayConnected
? "The gateway did not return dream diary content."
: "Connect a gateway to read dream diary entries.")
.padding(14)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func dreamDiaryDayMenu(days: [DreamDiaryDay], selectedDay: DreamDiaryDay?) -> some View {
Menu {
ForEach(Array(days.reversed())) { day in
Button {
self.selectedDreamDiaryDayID = day.id
} label: {
Label(
day.title,
systemImage: day.id == selectedDay?.id ? "checkmark.circle.fill" : "calendar")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
} label: {
HStack(spacing: 6) {
Image(systemName: "calendar")
Text(selectedDay?.title ?? "Day")
.font(OpenClawType.captionSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.font(OpenClawType.captionSemiBold)
.foregroundStyle(.primary)
.padding(.horizontal, 10)
.frame(height: 34)
.background(Color.primary.opacity(0.055), in: Capsule())
}
.accessibilityLabel("Dream diary day")
}
private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text(day.title)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Spacer(minLength: 8)
Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")")
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(OpenClawBrand.accent)
}
Text(day.body)
.font(OpenClawType.monoSmall)
.foregroundStyle(.primary)
.lineLimit(120)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.background(
Color.primary.opacity(0.045),
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
}
private func selectedDreamDiaryDay(from days: [DreamDiaryDay]) -> DreamDiaryDay? {
if let selectedDreamDiaryDayID,
let match = days.first(where: { $0.id == selectedDreamDiaryDayID })
{
return match
}
return days.last
}
private func dreamingEntriesList(
title: String,
entries: [DreamingEntryLite],
emptyTitle: String,
emptyDetail: String) -> some View
{
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: title)
ProCard(padding: 0) {
if entries.isEmpty {
self.emptyDetailRow(
icon: "doc.text.magnifyingglass",
title: emptyTitle,
detail: self.gatewayConnected ? emptyDetail : "Connect a gateway to load dreaming entries.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
self.dreamingEntryRow(entry)
if index < entries.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func dreamingEntryRow(_ entry: DreamingEntryLite) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: "text.page", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 4) {
Text(self.dreamingEntryTitle(entry))
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Text(entry.snippet)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(4)
.textSelection(.enabled)
Text(self.dreamingEntryDetail(entry))
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text("\(entry.totalSignalCount)")
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(OpenClawBrand.accent)
.lineLimit(1)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private var dreamingPhasesCard: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Phases")
ProCard(padding: 0) {
let phases = self.dreamingPhases
if phases.isEmpty {
self.emptyDetailRow(
icon: "moon.zzz",
title: self.gatewayConnected ? "No phase status" : "Dreaming unavailable",
detail: self.gatewayConnected
? "The gateway did not return dreaming phase details."
: "Connect a gateway to load dreaming phases.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(phases.enumerated()), id: \.element.id) { index, phase in
self.dreamingPhaseRow(phase)
if index < phases.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var dreamingPhases: [DreamingPhaseRow] {
let phaseOrder = ["light", "deep", "rem"]
let phases = self.overview?.dreaming?.phases ?? [:]
return phaseOrder.compactMap { id in
guard let phase = phases[id] else { return nil }
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
}
}
private func dreamingPhaseRow(_ phase: DreamingPhaseRow) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(
systemName: phase.status.enabled == false ? "pause.circle" : "moon.stars",
color: phase.status.enabled == false ? .secondary : OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 4) {
Text(phase.title)
.font(OpenClawType.subheadSemiBold)
Text(self.dreamingPhaseDetail(phase.status))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let cron = self.normalized(phase.status.cron) {
Text(cron)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(self.dreamingPhaseState(phase.status))
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(phase.status.managedCronPresent == true ? OpenClawBrand.accent : .secondary)
.lineLimit(1)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.subheadSemiBold)
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
private func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
Text(value)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
Color.primary.opacity(0.055),
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
}
private func dreamingEntryTitle(_ entry: DreamingEntryLite) -> String {
let path = entry.path.split(separator: "/").last.map(String.init) ?? entry.path
return "\(path):\(entry.startLine)"
}
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
let parts = [
entry.promotedAt.map { "promoted \($0)" },
entry.lastRecalledAt.map { "recalled \($0)" },
"\(entry.recallCount) recalls",
"\(entry.groundedCount) grounded",
].compactMap(\.self)
return parts.joined(separator: "")
}
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
if let nextRunAtMs = phase.nextRunAtMs {
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
}
if phase.managedCronPresent == true {
return "Managed cron is installed."
}
return "Managed cron is not installed."
}
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
if phase.enabled == false { return "off" }
return phase.managedCronPresent == true ? "scheduled" : "setup"
}
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
}
@MainActor
private func runDreamAction(_ action: DreamAction) async {
guard self.gatewayConnected, self.dreamActionBusy == nil else { return }
self.dreamActionBusy = action
self.dreamActionStatusText = nil
defer { self.dreamActionBusy = nil }
do {
let data = try await self.appModel.operatorSession.request(
method: action.method,
paramsJSON: "{}",
timeoutSeconds: 30)
self.dreamActionStatusText = Self.dreamActionSummary(action: action, data: data)
await self.refresh()
} catch {
self.dreamActionStatusText = error.localizedDescription
}
}
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return "\(action.title) complete."
}
let written = json["written"] as? Int
let replaced = json["replaced"] as? Int
let removed = json["removedEntries"] as? Int
let changed = json["changed"] as? Bool
let parts = [
written.map { "\($0) written" },
replaced.map { "\($0) replaced" },
removed.map { "\($0) removed" },
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
].compactMap(\.self)
if parts.isEmpty {
return "\(action.title) complete."
}
return "\(action.title): \(parts.joined(separator: ", "))."
}
private func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func normalizedMultiline(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func compactNumber(_ value: Int) -> String {
value.formatted(.number.notation(.compactName))
}
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
private static func dreamDiaryDays(from content: String) -> [DreamDiaryDay] {
let inner = Self.dreamDiaryInnerContent(content)
let separatorBlocks = inner
.components(separatedBy: "\n---")
.flatMap { $0.components(separatedBy: "\r\n---") }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let blocks = separatorBlocks.count > 1 ? separatorBlocks : Self.splitDiaryBlocksByDateLine(inner)
let parsedBlocks = blocks.enumerated().map { index, block in
Self.dreamDiaryBlock(from: block, index: index)
}.filter(\.hasDatedEntry)
return Self.mergeDiaryBlocksByDay(parsedBlocks)
}
private static func dreamDiaryInnerContent(_ content: String) -> String {
let start = "<!-- openclaw:dreaming:diary:start -->"
let end = "<!-- openclaw:dreaming:diary:end -->"
guard let startRange = content.range(of: start),
let endRange = content.range(of: end, range: startRange.upperBound..<content.endIndex)
else {
return content
}
return String(content[startRange.upperBound..<endRange.lowerBound])
}
private static func dreamDiaryBlock(from block: String, index: Int) -> DreamDiaryDay {
let rawLines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let dateLineIndex = rawLines.firstIndex { line in
Self.isDiaryDateLine(line)
}
let markerDay = rawLines.compactMap(Self.backfillDay).first
let rawTitle = dateLineIndex.flatMap { Self.unwrappedEmphasis(rawLines[$0]) } ?? markerDay
let title = rawTitle.map(Self.dayTitle) ?? markerDay ?? "Diary"
let id = markerDay ?? Self.dayID(title)
let bodyLines = rawLines.enumerated().compactMap { offset, line -> String? in
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if offset == dateLineIndex { return nil }
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return nil }
if trimmed == "#" || trimmed == "# Dream Diary" { return nil }
return line
}
let body = bodyLines
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return DreamDiaryDay(
id: id.isEmpty ? "\(index)" : id,
title: title,
body: body.isEmpty ? "No diary prose for this day." : body,
entryCount: 1,
hasDatedEntry: rawTitle != nil)
}
private static func mergeDiaryBlocksByDay(_ blocks: [DreamDiaryDay]) -> [DreamDiaryDay] {
var ordered: [DreamDiaryDay] = []
for block in blocks {
if let existingIndex = ordered.firstIndex(where: { $0.title == block.title }) {
let existing = ordered[existingIndex]
ordered[existingIndex] = DreamDiaryDay(
id: existing.id,
title: existing.title,
body: [existing.body, block.body].joined(separator: "\n\n---\n\n"),
entryCount: existing.entryCount + block.entryCount,
hasDatedEntry: true)
} else {
ordered.append(block)
}
}
return ordered
}
private static func splitDiaryBlocksByDateLine(_ content: String) -> [String] {
var blocks: [String] = []
var current: [String] = []
for line in content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if Self.isDiaryDateLine(line), !current.isEmpty {
blocks.append(current.joined(separator: "\n"))
current = []
}
current.append(line)
}
if !current.isEmpty {
blocks.append(current.joined(separator: "\n"))
}
return blocks
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private static func isDiaryDateLine(_ line: String) -> Bool {
guard let value = unwrappedEmphasis(line) else { return false }
let monthNames = "January|February|March|April|May|June|July|August|September|October|November|December"
let monthDatePattern = #"\b("# + monthNames + #")\s+\d{1,2},\s+\d{4}\b"#
let isoDatePattern = #"\b\d{4}-\d{2}-\d{2}\b"#
return value.range(
of: "\(monthDatePattern)|\(isoDatePattern)",
options: .regularExpression) != nil
}
private static func dayTitle(_ rawTitle: String) -> String {
let noTime = rawTitle.replacingOccurrences(
of: #"\s+at\s+\d{1,2}:\d{2}.*$"#,
with: "",
options: .regularExpression)
return noTime.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func dayID(_ title: String) -> String {
title.lowercased()
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
}
private static func unwrappedEmphasis(_ line: String) -> String? {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("*"), trimmed.hasSuffix("*"), trimmed.count > 2 else { return nil }
return String(trimmed.dropFirst().dropLast())
}
private static func backfillDay(_ line: String) -> String? {
guard let range = line.range(of: #"day=\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
return nil
}
return String(line[range].dropFirst(4))
}
}
private struct DreamDiaryDay: Identifiable {
let id: String
let title: String
let body: String
let entryCount: Int
let hasDatedEntry: Bool
}

View File

@@ -0,0 +1,378 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
enum AgentProValueReader {
static func intValue(_ value: AnyCodable?) -> Int? {
switch value?.value {
case let int as Int: int
case let double as Double where double.isFinite: Int(double)
case let string as String: Int(string)
default: nil
}
}
static func doubleValue(_ value: AnyCodable?) -> Double? {
switch value?.value {
case let double as Double where double.isFinite: double
case let int as Int: Double(int)
case let string as String: Double(string)
default: nil
}
}
}
struct AgentOverviewSnapshot {
let skills: SkillStatusReportLite?
let presence: [PresenceEntry]
let cronStatus: CronStatusLite?
let cronJobs: [CronJob]
let dreaming: DreamingStatusLite?
let dreamDiary: DreamDiaryLite?
let usage: CostUsageSummaryLite?
let activeAgentId: String
let agentSkillFilter: [String]?
let loadedAt: Date
var hasAnyLiveData: Bool {
self.skills != nil
|| !self.presence.isEmpty
|| self.cronStatus != nil
|| !self.cronJobs.isEmpty
|| self.dreaming != nil
|| self.dreamDiary != nil
|| self.usage != nil
}
}
struct SkillStatusReportLite: Decodable {
let workspaceDir: String?
let managedSkillsDir: String?
let agentId: String?
let agentSkillFilter: [String]?
let skills: [SkillStatusEntryLite]
var totalCount: Int {
self.skills.count
}
var enabledCount: Int {
self.skills.count {
$0.isEnabled
}
}
var blockedCount: Int {
self.skills.count {
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
}
}
var missingRequirementCount: Int {
self.skills.count {
$0.hasMissingRequirements
}
}
}
struct SkillStatusEntryLite: Decodable {
let name: String
let description: String?
let source: String?
let filePath: String?
let skillKey: String?
let primaryEnv: String?
let emoji: String?
let homepage: String?
let disabled: Bool?
let blockedByAllowlist: Bool?
let blockedByAgentFilter: Bool?
let missing: SkillStatusMissingLite?
let install: [SkillInstallOptionLite]?
var displayName: String {
if let emoji, !emoji.isEmpty {
return "\(emoji) \(self.name)"
}
return self.name
}
var effectiveSkillKey: String {
let trimmed = (self.skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? self.name : trimmed
}
var isGloballyEnabled: Bool {
self.disabled != true
}
var isEnabled: Bool {
self.disabled != true
&& self.blockedByAllowlist != true
&& self.blockedByAgentFilter != true
}
var hasMissingRequirements: Bool {
guard let missing else { return false }
return !missing.bins.isEmpty
|| !missing.env.isEmpty
|| !missing.config.isEmpty
|| !missing.os.isEmpty
}
var missingSummary: String? {
guard let missing else { return nil }
let values = [
missing.bins,
missing.env,
missing.config,
missing.os,
].flatMap(\.self)
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
}
var installSummary: String? {
guard let option = self.install?.first else { return nil }
return option.label
}
var missingBins: [String] {
self.missing?.bins ?? []
}
var homepageURL: URL? {
guard let homepage else { return nil }
return URL(string: homepage)
}
}
struct SkillInstallOptionLite: Decodable {
let id: String?
let kind: String?
let label: String
let bins: [String]?
}
struct SkillUpdateParams: Encodable {
let skillKey: String
var enabled: Bool?
var apiKey: String?
}
struct SkillInstallParams: Encodable {
let name: String
let installId: String
let timeoutMs: Int
}
struct SkillInstallResultLite: Decodable {
let message: String?
}
struct ClawHubSearchParams: Encodable {
let query: String?
let limit: Int
}
struct ClawHubSearchResponseLite: Decodable {
let results: [ClawHubSearchResultLite]
}
struct ClawHubSearchResultLite: Decodable {
let slug: String
let displayName: String
let summary: String?
let version: String?
}
struct ClawHubInstallParams: Encodable {
let source = "clawhub"
let slug: String
}
struct CronRunParams: Encodable {
let id: String
let mode: String
}
struct CronUpdatePatch: Encodable {
let enabled: Bool
}
struct CronUpdateParams: Encodable {
let id: String
let patch: CronUpdatePatch
}
struct SkillStatusMissingLite: Decodable {
let bins: [String]
let env: [String]
let config: [String]
let os: [String]
}
struct CronStatusLite: Decodable {
let enabled: Bool
let jobs: Int
let nextwakeatms: Int?
enum CodingKeys: String, CodingKey {
case enabled
case jobs
case nextwakeatms = "nextWakeAtMs"
}
}
struct CronJobsListLite: Decodable {
let jobs: [CronJob]
let total: Int?
}
struct DreamingStatusEnvelope: Decodable {
let dreaming: DreamingStatusLite?
}
struct DreamingStatusLite: Decodable {
let enabled: Bool
let shortTermCount: Int?
let totalSignalCount: Int?
let promotedToday: Int?
let storeError: String?
let shortTermEntries: [DreamingEntryLite]?
let signalEntries: [DreamingEntryLite]?
let promotedEntries: [DreamingEntryLite]?
let phases: [String: DreamingPhaseStatusLite]?
var nextRunAtMs: Int? {
self.phases?.values
.compactMap(\.nextRunAtMs)
.min()
}
}
struct DreamingEntryLite: Decodable, Identifiable {
let key: String
let path: String
let startLine: Int
let endLine: Int
let snippet: String
let recallCount: Int
let dailyCount: Int
let groundedCount: Int
let totalSignalCount: Int
let lightHits: Int
let remHits: Int
let phaseHitCount: Int
let promotedAt: String?
let lastRecalledAt: String?
var id: String {
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
}
}
struct DreamDiaryLite: Decodable {
let agentId: String
let found: Bool
let path: String
let content: String?
let updatedAtMs: Int?
}
struct DreamingPhaseStatusLite: Decodable {
let enabled: Bool?
let cron: String?
let managedCronPresent: Bool?
let nextRunAtMs: Int?
}
struct DreamingPhaseRow: Identifiable {
let id: String
let title: String
let status: DreamingPhaseStatusLite
}
struct ConfigSnapshotLite: Decodable {
let hash: String?
let config: ConfigRootLite?
func agentConfig(id: String) -> AgentConfigLite? {
self.config?.agents?.list?.first { $0.id == id }
}
func effectiveSkillFilter(agentId: String) -> [String]? {
if let agentSkills = self.agentConfig(id: agentId)?.skills {
return agentSkills
}
return self.config?.agents?.defaults?.skills
}
}
struct ConfigRootLite: Decodable {
let agents: AgentsConfigLite?
}
struct AgentsConfigLite: Decodable {
let defaults: AgentDefaultsConfigLite?
let list: [AgentConfigLite]?
}
struct AgentDefaultsConfigLite: Decodable {
let skills: [String]?
}
struct AgentConfigLite: Decodable {
let id: String
let skills: [String]?
}
struct ConfigPatchParams: Encodable {
let raw: String
let baseHash: String
let replacePaths: [String]?
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
self.raw = raw
self.baseHash = baseHash
self.replacePaths = replacePaths
}
}
enum SkillMutationError: LocalizedError {
case liveGatewayUnavailable
case missingConfigHash
case invalidPatchPayload
var errorDescription: String? {
switch self {
case .liveGatewayUnavailable:
"Connect a live gateway to edit agent skills."
case .missingConfigHash:
"Config hash missing; refresh and retry."
case .invalidPatchPayload:
"Could not encode the skill config update."
}
}
}
struct CostUsageSummaryLite: Decodable {
let updatedAt: Int?
let days: Int?
let daily: [CostUsageDailyEntryLite]?
let totals: [String: AnyCodable]?
let cacheStatus: [String: AnyCodable]?
var totalCost: Double? {
AgentProValueReader.doubleValue(self.totals?["totalCost"])
}
var totalTokens: Int? {
AgentProValueReader.intValue(self.totals?["totalTokens"])
}
}
struct CostUsageDailyEntryLite: Decodable {
let date: String
let totalTokens: Int?
let totalCost: Double?
}

View File

@@ -0,0 +1,373 @@
import OpenClawProtocol
import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let agentCount: Int
let instancesValue: String
let instancesDetail: String
let instancesColor: Color
let refresh: () async -> Void
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.summaryCard
self.totalsCard
self.nodesList
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.refreshable {
await self.refresh()
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Instances")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Instances",
subtitle: self.instancesDetail,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.subheadMedium)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var summaryCard: some View {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: "display", color: self.instancesColor)
VStack(alignment: .leading, spacing: 3) {
Text("Instances")
.font(OpenClawType.headline)
Text(self.instancesDetail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: self.instancesValue, color: self.instancesColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var totalsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Presence")
.font(OpenClawType.headline)
Spacer()
ProValuePill(value: self.instancesValue, color: self.instancesColor)
}
HStack(spacing: 10) {
self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)")
self.detailMetric(label: "Agents", value: "\(self.agentCount)")
self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline")
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var nodesList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Connected Instances")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
detail: self.gatewayConnected
? "The gateway did not report any system presence entries."
: "Connect a gateway to inspect connected instances.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(nodes.enumerated()), id: \.element.presenceKey) { index, entry in
NavigationLink {
self.nodeDetail(entry)
} label: {
self.nodePresenceRow(entry, showsChevron: true)
}
.buttonStyle(.plain)
if index < nodes.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var sortedPresenceEntries: [PresenceEntry] {
(self.overview?.presence ?? [])
.sorted { lhs, rhs in
if lhs.ts != rhs.ts { return lhs.ts > rhs.ts }
return (Self.presenceLabel(lhs) ?? lhs.presenceKey)
.localizedCaseInsensitiveCompare(Self.presenceLabel(rhs) ?? rhs.presenceKey) == .orderedAscending
}
}
private func nodePresenceRow(_ entry: PresenceEntry, showsChevron: Bool = false) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 4) {
Text(Self.presenceLabel(entry) ?? "Instance")
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Text(Self.presenceDetail(entry))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let meta = Self.presenceMeta(entry) {
Text(meta)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(Self.presenceState(entry))
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(Self.presenceColor(entry))
.lineLimit(1)
if showsChevron {
Image(systemName: "chevron.right")
.font(OpenClawType.caption2Bold)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private func nodeDetail(_ entry: PresenceEntry) -> some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 3) {
Text(Self.presenceLabel(entry) ?? "Instance")
.font(OpenClawType.headline)
Text(Self.presenceDetail(entry))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: Self.presenceState(entry), color: Self.presenceColor(entry))
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
ProCard {
VStack(spacing: 0) {
self.nodeDetailRow("Instance", value: entry.instanceid)
Divider()
self.nodeDetailRow("Device", value: entry.deviceid)
Divider()
self.nodeDetailRow("Host", value: entry.host)
Divider()
self.nodeDetailRow("IP", value: entry.ip)
Divider()
self.nodeDetailRow("Platform", value: entry.platform)
Divider()
self.nodeDetailRow("Version", value: entry.version)
Divider()
self.nodeDetailRow("Mode", value: entry.mode)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.nodeListCard(title: "Scopes", values: entry.scopes ?? [])
self.nodeListCard(title: "Roles", values: entry.roles ?? [])
self.nodeListCard(title: "Tags", values: entry.tags ?? [])
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
.navigationBarTitleDisplayMode(.inline)
}
private func nodeDetailRow(_ title: String, value: String?) -> some View {
let normalized = Self.normalized(value) ?? "n/a"
return HStack(spacing: 10) {
Text(title)
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(normalized)
.font(OpenClawType.subhead)
.lineLimit(1)
.truncationMode(.middle)
Button {
UIPasteboard.general.string = normalized
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.plain)
.disabled(normalized == "n/a")
.accessibilityLabel("Copy \(title)")
}
.font(OpenClawType.subhead)
.padding(.vertical, 10)
}
private func nodeListCard(title: String, values: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: title)
ProCard {
if values.isEmpty {
Text("None reported.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(values, id: \.self) { value in
Text(value)
.font(OpenClawType.monoSmall)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
Text(value)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
Color.primary.opacity(0.055),
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
}
private func emptyRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.subheadSemiBold)
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
private static func presenceLabel(_ entry: PresenceEntry) -> String? {
self.normalized(entry.host)
?? self.normalized(entry.devicefamily)
?? self.normalized(entry.platform)
?? self.normalized(entry.mode)
}
private static func presenceDetail(_ entry: PresenceEntry) -> String {
let parts = [
Self.normalized(entry.ip),
Self.normalized(entry.platform),
Self.normalized(entry.version),
].compactMap(\.self)
if !parts.isEmpty {
return parts.joined(separator: "")
}
return Self.normalized(entry.text) ?? "Presence beacon received."
}
private static func presenceMeta(_ entry: PresenceEntry) -> String? {
let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ")
let scopesCount = entry.scopes?.count ?? 0
let rolesCount = entry.roles?.count ?? 0
let labels = [
Self.normalized(entry.instanceid).map { "instance \($0)" },
tags.isEmpty ? nil : tags,
scopesCount > 0 ? "\(scopesCount) scopes" : nil,
rolesCount > 0 ? "\(rolesCount) roles" : nil,
].compactMap(\.self)
return labels.isEmpty ? nil : labels.joined(separator: "")
}
private static func presenceState(_ entry: PresenceEntry) -> String {
if let reason = normalized(entry.reason) {
return reason
}
if let mode = Self.normalized(entry.mode) {
return mode
}
return Self.relativeTime(fromMilliseconds: entry.ts)
}
private static func presenceIcon(_ entry: PresenceEntry) -> String {
let family = Self.normalized(entry.devicefamily)?.lowercased()
if family?.contains("phone") == true { return "iphone" }
if family?.contains("tablet") == true || family?.contains("pad") == true { return "ipad" }
if family?.contains("desktop") == true || family?.contains("mac") == true { return "desktopcomputer" }
return "display"
}
private static func presenceColor(_ entry: PresenceEntry) -> Color {
self.normalized(entry.reason) == nil ? OpenClawBrand.accent : OpenClawBrand.warn
}
private static func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
}
extension PresenceEntry {
fileprivate var presenceKey: String {
self.instanceid
?? self.deviceid
?? self.host
?? self.ip
?? "\(self.ts)"
}
}

View File

@@ -0,0 +1,180 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
var cronStatusCard: some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Scheduler")
.font(OpenClawType.headline)
Spacer()
ProValuePill(
value: self.overview?.cronStatus?.enabled == true ? "on" : "off",
color: self.cronColor)
}
HStack(spacing: 10) {
let jobCount = self.overview?.cronStatus?.jobs
?? self.overview?.cronJobs.count
?? 0
self.detailMetric(label: "Jobs", value: "\(jobCount)")
self.detailMetric(label: "Next", value: self.cronNextRunLabel)
}
if let cronActionStatusText {
Text(cronActionStatusText)
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var cronNextRunLabel: String {
guard let nextWakeAtMs = self.overview?.cronStatus?.nextwakeatms else { return "none" }
return Self.relativeTime(fromMilliseconds: nextWakeAtMs)
}
func cronJobsList(limit: Int?) -> some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Jobs")
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
let jobs = self.sortedCronJobs
let visible = limit.map { Array(jobs.prefix($0)) } ?? jobs
if visible.isEmpty {
self.emptyCronRow
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(visible.enumerated()), id: \.element.id) { index, job in
self.cronJobDetailRow(job)
if index < visible.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var sortedCronJobs: [CronJob] {
(self.overview?.cronJobs ?? [])
.sorted { lhs, rhs in
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
switch (lhsNext, rhsNext) {
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
}
func cronJobDetailRow(_ job: CronJob) -> some View {
let busy = self.cronActionBusyIDs.contains(job.id)
return HStack(alignment: .top, spacing: 12) {
ProIconBadge(
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
color: job.enabled ? OpenClawBrand.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(job.name)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
Text(self.cronJobDetail(job))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(self.cronScheduleSummary(job))
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
Button {
Task { await self.runCronJob(job) }
} label: {
Label("Run", systemImage: "play.fill")
.font(OpenClawType.captionSemiBold)
}
.disabled(busy || !self.liveGatewayConnected)
Button {
Task { await self.setCronJob(job, enabled: !job.enabled) }
} label: {
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
.font(OpenClawType.captionSemiBold)
}
.disabled(busy || !self.liveGatewayConnected)
}
.buttonStyle(.bordered)
.controlSize(.mini)
}
Spacer(minLength: 8)
if busy {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
} else {
Text(self.cronJobState(job))
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
.lineLimit(1)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
@MainActor
func runCronJob(_ job: CronJob) async {
await self.runCronAction(job, success: "Queued \(job.name).") {
let params = CronRunParams(id: job.id, mode: "force")
_ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20)
}
}
@MainActor
func setCronJob(_ job: CronJob, enabled: Bool) async {
await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") {
let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled))
_ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20)
}
}
@MainActor
func runCronAction(
_ job: CronJob,
success: String,
action: () async throws -> Void) async
{
guard self.liveGatewayConnected else { return }
self.cronActionBusyIDs.insert(job.id)
self.cronActionStatusText = nil
defer { self.cronActionBusyIDs.remove(job.id) }
do {
try await action()
self.cronActionStatusText = success
await self.refreshOverview(force: true)
} catch {
self.cronActionStatusText = Self.skillMutationMessage(error)
}
}
func cronScheduleSummary(_ job: CronJob) -> String {
guard let schedule = job.schedule.value as? [String: AnyCodable] else { return "Schedule configured" }
if let expr = Self.stringValue(schedule["expr"]) {
return "Cron \(expr)"
}
if let everyMs = AgentProValueReader.intValue(schedule["everyMs"]) {
return "Every \(Self.duration(milliseconds: everyMs))"
}
if let kind = Self.stringValue(schedule["kind"]) {
return kind
}
return "Schedule configured"
}
}

View File

@@ -0,0 +1,217 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .agents:
self.agentsDestination
case .skills:
self.skillsDestination
case .instances:
self.instancesDestination
case .cron:
self.cronDestination
case .usage:
self.usageDestination
case .dreaming:
self.dreamingDestination
}
}
var agentsDestination: some View {
List {
Section {
if self.filteredAgents.isEmpty {
self.emptyAgentsRow
} else {
ForEach(self.filteredAgents, id: \.id) { agent in
self.agentRow(agent)
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(self.headerTitle)
.navigationBarTitleDisplayMode(.large)
.searchable(text: self.$agentSearchText, prompt: "Search agents")
.refreshable {
await self.refreshOverview(force: true)
}
.font(OpenClawType.body)
.toolbar {
if let headerLeadingAction {
ToolbarItem(placement: .topBarLeading) {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
self.agentFilterMenu
self.gatewayToolbarButton
}
}
}
var skillsDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.detailSummaryCard(
icon: "sparkles",
title: "Skills",
value: self.skillsValue,
detail: self.skillsDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
self.skillsPolicyControls
self.skillsFilterField
self.clawHubSearchCard
self.skillsList
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Skills")
.navigationBarTitleDisplayMode(.inline)
}
var instancesDestination: some View {
AgentProNodesDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
instancesValue: self.instancesValue,
instancesDetail: self.instancesDetail,
instancesColor: self.instancesColor,
refresh: {
await self.refreshOverview(force: true)
})
}
var cronDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .cron,
title: "Cron Jobs",
subtitle: self.cronDetail)
self.detailSummaryCard(
icon: "clock.arrow.circlepath",
title: "Cron Jobs",
value: self.cronValue,
detail: self.cronDetail,
color: self.cronColor)
self.cronStatusCard
self.cronJobsList(limit: nil)
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Cron Jobs")
.navigationBarTitleDisplayMode(.inline)
}
var usageDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .usage,
title: "Usage",
subtitle: self.usageDetail)
self.detailSummaryCard(
icon: "chart.line.uptrend.xyaxis",
title: "Usage",
value: self.usageValue,
detail: self.usageDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
self.usageTotalsCard
self.usageDailyList
}
.padding(.vertical, 18)
.font(OpenClawType.body)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Usage")
.navigationBarTitleDisplayMode(.inline)
}
var dreamingDestination: some View {
AgentProDreamingDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
dreamingValue: self.dreamingValue,
dreamingDetail: self.dreamingDetail,
dreamingColor: self.dreamingColor,
refresh: {
await self.refreshOverview(force: true)
})
}
@ViewBuilder
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
OpenClawAdaptiveHeaderRow(
title: title,
subtitle: subtitle,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.subheadMedium)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
self.directRoute == route ? self.headerLeadingAction : nil
}
func detailSummaryCard(
icon: String,
title: String,
value: String,
detail: String,
color: Color) -> some View
{
ProCard(radius: AgentLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.headline)
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}

View File

@@ -0,0 +1,36 @@
import OpenClawKit
import SwiftUI
extension AgentProTab {
func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
Text(value)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
Color.primary.opacity(0.055),
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
}
func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.subheadSemiBold)
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
}

View File

@@ -0,0 +1,223 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
func agentName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
func agentBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = self.normalized(emoji)
{
return normalizedEmoji
}
let words = self.agentName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap(\.first).map(String.init).joined()
return initials.isEmpty ? "OC" : initials.uppercased()
}
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
return state.color.opacity(0.62)
}
func agentDetail(for agent: AgentSummary) -> String {
let parts = [
self.modelLabel(for: agent),
agent.id == self.appModel.gatewayDefaultAgentId ? "Default" : nil,
].compactMap(\.self)
return parts.isEmpty ? agent.id : parts.joined(separator: "")
}
func agentAccessibilityLabel(
_ agent: AgentSummary,
isActive: Bool,
state: AgentRosterState) -> String
{
let status = state == .online ? "Online" : "Ready"
let selection = isActive ? "Selected" : "Not selected"
return "\(self.agentName(for: agent)), \(self.agentDetail(for: agent)), \(status), \(selection)"
}
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
guard self.gatewayConnected else { return .ready }
if agent.id == self.activeAgentID { return .online }
return .ready
}
func modelLabel(for agent: AgentSummary) -> String? {
guard let model = agent.model else { return nil }
for key in ["primary", "name", "id", "model"] {
if let value = model[key]?.value as? String,
let normalized = self.normalized(value)
{
return normalized
}
}
return nil
}
func presenceLabel(_ entry: PresenceEntry) -> String? {
self.normalized(entry.host)
?? self.normalized(entry.devicefamily)
?? self.normalized(entry.platform)
?? self.normalized(entry.mode)
}
func cronJobDetail(_ job: CronJob) -> String {
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
}
if let description = self.normalized(job.description) {
return description
}
if let agentId = self.normalized(job.agentid) {
return agentId
}
return job.id
}
func cronJobState(_ job: CronJob) -> String {
if !job.enabled {
return "paused"
}
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
return status
}
return "enabled"
}
@MainActor
func refreshOverview(force: Bool) async {
guard self.scenePhase == .active else { return }
guard self.liveGatewayConnected else {
self.overview = nil
self.overviewErrorText = nil
self.overviewLoading = false
return
}
if self.overviewLoading, force == false {
return
}
self.overviewLoading = true
self.overviewErrorText = nil
defer { self.overviewLoading = false }
let activeAgentID = self.activeAgentID
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
async let skills = self.requestOptional(
SkillStatusReportLite.self,
method: "skills.status",
paramsJSON: skillsParams)
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
async let cronJobs = self.requestOptional(
CronJobsListLite.self,
method: "cron.list",
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
timeoutSeconds: 12)
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
async let usage = self.requestOptional(
CostUsageSummaryLite.self,
method: "usage.cost",
paramsJSON: "{\"days\":31}",
timeoutSeconds: 12)
let loadedSkills = await skills
let loadedConfig = await config
let loadedPresence = await presence
let loadedCronStatus = await cronStatus
let loadedCronJobs = await cronJobs
let loadedDreaming = await dreaming
let loadedDreamDiary = await dreamDiary
let loadedUsage = await usage
let snapshot = AgentOverviewSnapshot(
skills: loadedSkills,
presence: loadedPresence ?? [],
cronStatus: loadedCronStatus,
cronJobs: loadedCronJobs?.jobs ?? [],
dreaming: loadedDreaming?.dreaming,
dreamDiary: loadedDreamDiary,
usage: loadedUsage,
activeAgentId: activeAgentID,
agentSkillFilter: loadedSkills?.agentSkillFilter
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
loadedAt: Date())
if snapshot.hasAnyLiveData {
self.overview = snapshot
} else {
self.overview = snapshot
self.overviewErrorText = "Live overview could not load yet."
}
}
func requestOptional<T: Decodable>(
_ type: T.Type,
method: String,
paramsJSON: String = "{}",
timeoutSeconds: Int = 8) async -> T?
{
do {
let data = try await self.appModel.operatorSession.request(
method: method,
paramsJSON: paramsJSON,
timeoutSeconds: timeoutSeconds)
return try JSONDecoder().decode(T.self, from: data)
} catch {
return nil
}
}
func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func stringValue(_ value: AnyCodable?) -> String? {
guard let string = value?.value as? String else { return nil }
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
static func compactNumber(_ value: Int) -> String {
value.formatted(.number.notation(.compactName))
}
static func currency(_ value: Double) -> String {
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
}
static func duration(milliseconds: Int) -> String {
let seconds = max(0, milliseconds / 1000)
if seconds < 60 { return "\(seconds)s" }
let minutes = seconds / 60
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
static func agentScopedParams(agentId: String) -> String {
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
let json = String(data: data, encoding: .utf8)
else {
return "{}"
}
return json
}
}

Some files were not shown because too many files have changed in this diff Show More