mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat: share to openclaw ios app (#19424)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 0a7ab8589a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -88,3 +88,6 @@ USER.md
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
|
||||
# Local iOS signing overrides
|
||||
apps/ios/LocalSigning.xcconfig
|
||||
|
||||
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
|
||||
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
|
||||
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
|
||||
- iOS/Share: add an iOS share extension that forwards shared URL/text/image content directly to gateway `agent.request`, with delivery-route fallback and optional receipt acknowledgements. (#19424) Thanks @mbelinky.
|
||||
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
|
||||
- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg.
|
||||
- Mattermost: add emoji reaction actions plus reaction event notifications, including an explicit boolean `remove` flag to avoid accidental removals. (#18608) Thanks @echo931.
|
||||
|
||||
12
apps/ios/LocalSigning.xcconfig.example
Normal file
12
apps/ios/LocalSigning.xcconfig.example
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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_DEVELOPMENT_TEAM = P5Z8X89DJL
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share
|
||||
|
||||
// Leave empty with automatic signing.
|
||||
OPENCLAW_APP_PROFILE =
|
||||
OPENCLAW_SHARE_PROFILE =
|
||||
43
apps/ios/ShareExtension/Info.plist
Normal file
43
apps/ios/ShareExtension/Info.plist
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClaw Share</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>10</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>
|
||||
</dict>
|
||||
</plist>
|
||||
524
apps/ios/ShareExtension/ShareViewController.swift
Normal file
524
apps/ios/ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,524 @@
|
||||
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.openclaw.ios", 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("Send to OpenClaw", 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("Cancel", 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("Preparing share…")
|
||||
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) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(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("Add a message, then tap Send.")
|
||||
} else {
|
||||
ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.")
|
||||
self.showStatus("Edit text, then tap Send.")
|
||||
}
|
||||
}
|
||||
|
||||
@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("Message is empty.")
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.isSending = true
|
||||
self.sendButton.isEnabled = false
|
||||
self.cancelButton.isEnabled = false
|
||||
}
|
||||
self.showStatus("Sending to OpenClaw gateway…")
|
||||
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("Sent to OpenClaw.")
|
||||
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("Send failed: \(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: "OpenClaw is not connected to a gateway yet."])
|
||||
}
|
||||
guard let url = URL(string: config.gatewayURLString) else {
|
||||
throw NSError(
|
||||
domain: "OpenClawShare",
|
||||
code: 11,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."])
|
||||
}
|
||||
|
||||
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",
|
||||
includeDeviceIdentity: false)
|
||||
}
|
||||
|
||||
do {
|
||||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
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 text = error.localizedDescription.lowercased()
|
||||
let expectsLegacyClientId = text.contains("invalid connect params") && text.contains("/client/id")
|
||||
guard expectsLegacyClientId else { throw error }
|
||||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
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 params = AgentRequestPayload(
|
||||
message: message,
|
||||
sessionKey: config.sessionKey,
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
receipt: true,
|
||||
receiptText: "Just received your iOS share + request, working on it.",
|
||||
to: config.deliveryTo,
|
||||
channel: config.deliveryChannel,
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/ios/Signing.xcconfig
Normal file
13
apps/ios/Signing.xcconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
// Default signing values for shared/repo builds.
|
||||
// For local development overrides, create LocalSigning.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
|
||||
OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share
|
||||
|
||||
OPENCLAW_APP_PROFILE = ai.openclaw.ios Development
|
||||
OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development
|
||||
|
||||
#include? "LocalSigning.xcconfig"
|
||||
@@ -18,8 +18,6 @@
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -2,6 +2,7 @@ import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Observation
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@@ -39,6 +40,7 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NodeAppModel {
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
case recording
|
||||
@@ -67,6 +69,8 @@ final class NodeAppModel {
|
||||
var selectedAgentId: String?
|
||||
var gatewayDefaultAgentId: String?
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
|
||||
var mainSessionKey: String {
|
||||
let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey)
|
||||
@@ -120,6 +124,8 @@ final class NodeAppModel {
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
private var shareDeliveryChannel: String?
|
||||
private var shareDeliveryTo: String?
|
||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||
@@ -170,6 +176,7 @@ final class NodeAppModel {
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachGateway(self.operatorGateway)
|
||||
self.refreshLastShareEventFromRelay()
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
// Route through the coordinator so VoiceWake and Talk don't fight over the microphone.
|
||||
self.setTalkEnabled(talkEnabled)
|
||||
@@ -466,6 +473,16 @@ final class NodeAppModel {
|
||||
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
gatewayURLString: relay.gatewayURLString,
|
||||
token: relay.token,
|
||||
password: relay.password,
|
||||
sessionKey: self.mainSessionKey,
|
||||
deliveryChannel: relay.deliveryChannel ?? self.shareDeliveryChannel,
|
||||
deliveryTo: relay.deliveryTo ?? self.shareDeliveryTo))
|
||||
}
|
||||
}
|
||||
|
||||
func setGlobalWakeWords(_ words: [String]) async {
|
||||
@@ -637,22 +654,33 @@ final class NodeAppModel {
|
||||
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
self.deepLinkLogger.info(
|
||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
|
||||
)
|
||||
|
||||
if message.count > 20000 {
|
||||
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
|
||||
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
self.recordShareEvent("Failed: gateway not connected.")
|
||||
self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.screen.errorText = nil
|
||||
self.recordShareEvent("Sent to gateway (\(message.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link forwarded to gateway")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
||||
self.recordShareEvent("Failed: \(error.localizedDescription)")
|
||||
self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,8 +1483,9 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
func isLocationPreciseEnabled() -> Bool {
|
||||
if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "location.preciseEnabled")
|
||||
// iOS settings now expose a single location mode control.
|
||||
// Default location tool precision stays high unless a command explicitly requests balanced.
|
||||
true
|
||||
}
|
||||
|
||||
static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
@@ -1584,6 +1613,7 @@ extension NodeAppModel {
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
ShareGatewayRelaySettings.clearConfig()
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
}
|
||||
@@ -1658,6 +1688,7 @@ private extension NodeAppModel {
|
||||
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
await self.refreshShareRouteFromGateway()
|
||||
await self.startVoiceWakeSync()
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
},
|
||||
@@ -1744,6 +1775,21 @@ private extension NodeAppModel {
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
}
|
||||
let relayData = await MainActor.run {
|
||||
(
|
||||
sessionKey: self.mainSessionKey,
|
||||
deliveryChannel: self.shareDeliveryChannel,
|
||||
deliveryTo: self.shareDeliveryTo
|
||||
)
|
||||
}
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
gatewayURLString: url.absoluteString,
|
||||
token: token,
|
||||
password: password,
|
||||
sessionKey: relayData.sessionKey,
|
||||
deliveryChannel: relayData.deliveryChannel,
|
||||
deliveryTo: relayData.deliveryTo))
|
||||
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
@@ -1831,9 +1877,9 @@ private extension NodeAppModel {
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway, then tap Resume."
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
@@ -1901,6 +1947,96 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private func refreshShareRouteFromGateway() async {
|
||||
struct Params: Codable {
|
||||
var includeGlobal: Bool
|
||||
var includeUnknown: Bool
|
||||
var limit: Int
|
||||
}
|
||||
struct SessionRow: Decodable {
|
||||
var key: String
|
||||
var updatedAt: Double?
|
||||
var lastChannel: String?
|
||||
var lastTo: String?
|
||||
}
|
||||
struct SessionsListResult: Decodable {
|
||||
var sessions: [SessionRow]
|
||||
}
|
||||
|
||||
let normalize: (String?) -> String? = { raw in
|
||||
let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(
|
||||
Params(includeGlobal: true, includeUnknown: false, limit: 80))
|
||||
guard let json = String(data: data, encoding: .utf8) else { return }
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "sessions.list",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 10)
|
||||
let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response)
|
||||
let currentKey = self.mainSessionKey
|
||||
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let exactMatch = sorted.first { row in
|
||||
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
}
|
||||
let fallback = sorted.first { row in
|
||||
normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
}
|
||||
let selected = exactMatch ?? fallback
|
||||
let channel = normalize(selected?.lastChannel)
|
||||
let to = normalize(selected?.lastTo)
|
||||
|
||||
await MainActor.run {
|
||||
self.shareDeliveryChannel = channel
|
||||
self.shareDeliveryTo = to
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
gatewayURLString: relay.gatewayURLString,
|
||||
token: relay.token,
|
||||
password: relay.password,
|
||||
sessionKey: self.mainSessionKey,
|
||||
deliveryChannel: channel,
|
||||
deliveryTo: to))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
func runSharePipelineSelfTest() async {
|
||||
self.recordShareEvent("Share self-test running…")
|
||||
|
||||
let payload = SharedContentPayload(
|
||||
title: "OpenClaw Share Self-Test",
|
||||
url: URL(string: "https://openclaw.ai/share-self-test"),
|
||||
text: "Validate iOS share->deep-link->gateway forwarding.")
|
||||
guard let deepLink = ShareToAgentDeepLink.buildURL(
|
||||
from: payload,
|
||||
instruction: "Reply with: SHARE SELF-TEST OK")
|
||||
else {
|
||||
self.recordShareEvent("Self-test failed: could not build deep link.")
|
||||
return
|
||||
}
|
||||
|
||||
await self.handleDeepLink(url: deepLink)
|
||||
}
|
||||
|
||||
func refreshLastShareEventFromRelay() {
|
||||
if let event = ShareGatewayRelaySettings.loadLastEvent() {
|
||||
self.lastShareEventText = event
|
||||
}
|
||||
}
|
||||
|
||||
func recordShareEvent(_ text: String) {
|
||||
ShareGatewayRelaySettings.saveLastEvent(text)
|
||||
self.refreshLastShareEventFromRelay()
|
||||
}
|
||||
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
await self?.talkMode.reloadConfig()
|
||||
|
||||
@@ -44,6 +44,7 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
struct OnboardingWizardView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
|
||||
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
|
||||
@@ -66,6 +67,7 @@ struct OnboardingWizardView: View {
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var lastPairingAutoResumeAttemptAt: Date?
|
||||
|
||||
let allowSkip: Bool
|
||||
let onClose: () -> Void
|
||||
@@ -276,6 +278,10 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
self.onClose()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
self.attemptAutomaticPairingResumeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -517,27 +523,38 @@ struct OnboardingWizardView: View {
|
||||
|
||||
if self.issue.needsPairing {
|
||||
Section {
|
||||
Button("Copy: openclaw devices list") {
|
||||
UIPasteboard.general.string = "openclaw devices list"
|
||||
}
|
||||
|
||||
if let id = self.issue.requestId {
|
||||
Button("Copy: openclaw devices approve \(id)") {
|
||||
UIPasteboard.general.string = "openclaw devices approve \(id)"
|
||||
}
|
||||
} else {
|
||||
Button("Copy: openclaw devices approve <requestId>") {
|
||||
UIPasteboard.general.string = "openclaw devices approve <requestId>"
|
||||
}
|
||||
Button {
|
||||
self.resumeAfterPairingApproval()
|
||||
} label: {
|
||||
Label("Resume After Approval", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
} header: {
|
||||
Text("Pairing Approval")
|
||||
} footer: {
|
||||
Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
|
||||
let requestLine: String = {
|
||||
if let id = self.issue.requestId, !id.isEmpty {
|
||||
return "Request ID: \(id)"
|
||||
}
|
||||
return "Request ID: check `openclaw devices list`."
|
||||
}()
|
||||
Text(
|
||||
"Approve this device on the gateway.\n"
|
||||
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
|
||||
+ "2) `/pair approve` in Telegram\n"
|
||||
+ "\(requestLine)\n"
|
||||
+ "OpenClaw will also retry automatically when you return to this app.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
self.openQRScannerFromOnboarding()
|
||||
} label: {
|
||||
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button {
|
||||
Task { await self.retryLastAttempt() }
|
||||
} label: {
|
||||
@@ -549,20 +566,6 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button {
|
||||
self.resumeAfterPairingApproval()
|
||||
} label: {
|
||||
Label("Resume After Approval", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
|
||||
|
||||
Button {
|
||||
self.openQRScannerFromOnboarding()
|
||||
} label: {
|
||||
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,6 +680,19 @@ struct OnboardingWizardView: View {
|
||||
Task { await self.retryLastAttempt() }
|
||||
}
|
||||
|
||||
private func attemptAutomaticPairingResumeIfNeeded() {
|
||||
guard self.step == .auth else { return }
|
||||
guard self.issue.needsPairing else { return }
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
|
||||
let now = Date()
|
||||
if let last = self.lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 {
|
||||
return
|
||||
}
|
||||
self.lastPairingAutoResumeAttemptAt = now
|
||||
self.resumeAfterPairingApproval()
|
||||
}
|
||||
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
|
||||
@@ -146,6 +146,9 @@ struct RootCanvas: View {
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.presentedSheet = .chat
|
||||
}
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -6,6 +6,12 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SettingsTab: View {
|
||||
private struct FeatureHelp: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@@ -19,7 +25,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
@@ -37,11 +42,10 @@ struct SettingsTab: View {
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var talkElevenLabsApiKey: String = ""
|
||||
@State private var defaultShareInstruction: String = ""
|
||||
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
@@ -49,6 +53,7 @@ struct SettingsTab: View {
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
@State private var showResetOnboardingAlert: Bool = false
|
||||
@State private var activeFeatureHelp: FeatureHelp?
|
||||
@State private var suppressCredentialPersist: Bool = false
|
||||
|
||||
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
||||
@@ -243,30 +248,22 @@ struct SettingsTab: View {
|
||||
|
||||
Section("Device") {
|
||||
DisclosureGroup("Features") {
|
||||
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.featureToggle(
|
||||
"Voice Wake",
|
||||
isOn: self.$voiceWakeEnabled,
|
||||
help: "Enables wake-word activation to start a hands-free session.") { newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.featureToggle(
|
||||
"Talk Mode",
|
||||
isOn: self.$talkEnabled,
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
Text("Keep listening when the app is in the background. Uses more battery.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled)
|
||||
Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||
self.featureToggle(
|
||||
"Background Listening",
|
||||
isOn: self.$talkBackgroundEnabled,
|
||||
help: "Keeps listening while the app is backgrounded. Uses more battery.")
|
||||
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
@@ -276,29 +273,78 @@ struct SettingsTab: View {
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
}
|
||||
|
||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
self.featureToggle(
|
||||
"Allow Camera",
|
||||
isOn: self.$cameraEnabled,
|
||||
help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Location Access")
|
||||
Spacer()
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Location Access",
|
||||
message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Location Access info")
|
||||
}
|
||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
self.featureToggle(
|
||||
"Prevent Sleep",
|
||||
isOn: self.$preventSleep,
|
||||
help: "Keeps the screen awake while OpenClaw is open.")
|
||||
|
||||
Text("Always requires system permission and may prompt to open Settings.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
DisclosureGroup("Advanced") {
|
||||
self.featureToggle(
|
||||
"Voice Directive Hint",
|
||||
isOn: self.$talkVoiceDirectiveHintEnabled,
|
||||
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
|
||||
self.featureToggle(
|
||||
"Show Talk Button",
|
||||
isOn: self.$talkButtonEnabled,
|
||||
help: "Shows the floating Talk button in the main interface.")
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2 ... 6)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
HStack(spacing: 8) {
|
||||
Text("Default Share Instruction")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Default Share Instruction",
|
||||
message: "Appends this instruction when sharing content into OpenClaw from iOS.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Default Share Instruction info")
|
||||
}
|
||||
|
||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||
Text("Keeps the screen awake while OpenClaw is open.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||||
} label: {
|
||||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||||
}
|
||||
Text(self.appModel.lastShareEventText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisclosureGroup("Device Info") {
|
||||
@@ -306,19 +352,11 @@ struct SettingsTab: View {
|
||||
Text(self.instanceId)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
||||
.contextMenu {
|
||||
if let ip = self.localIPAddress {
|
||||
Button {
|
||||
UIPasteboard.general.string = ip
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
LabeledContent("Device", value: self.deviceFamily())
|
||||
LabeledContent("Platform", value: self.platformString())
|
||||
LabeledContent("Version", value: self.appVersion())
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
LabeledContent("OpenClaw", value: self.openClawVersionString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,8 +380,13 @@ struct SettingsTab: View {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
||||
}
|
||||
.alert(item: self.$activeFeatureHelp) { help in
|
||||
Alert(
|
||||
title: Text(help.title),
|
||||
message: Text(help.message),
|
||||
dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -351,7 +394,8 @@ struct SettingsTab: View {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.appModel.refreshLastShareEventFromRelay()
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
@@ -384,8 +428,8 @@ struct SettingsTab: View {
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.talkElevenLabsApiKey) { _, newValue in
|
||||
GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
@@ -518,14 +562,6 @@ struct SettingsTab: View {
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
}
|
||||
|
||||
private var locationMode: OpenClawLocationMode {
|
||||
OpenClawLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
}
|
||||
|
||||
private func deviceFamily() -> String {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case .pad:
|
||||
@@ -537,14 +573,36 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func modelIdentifier() -> String {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
||||
private func openClawVersionString() -> String {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedBuild.isEmpty || trimmedBuild == version {
|
||||
return version
|
||||
}
|
||||
return "\(version) (\(trimmedBuild))"
|
||||
}
|
||||
|
||||
private func featureToggle(
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
help: String,
|
||||
onChange: ((Bool) -> Void)? = nil
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle(title, isOn: isOn)
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(title: title, message: help)
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(title) info")
|
||||
}
|
||||
.onChange(of: isOn.wrappedValue) { _, newValue in
|
||||
onChange?(newValue)
|
||||
}
|
||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
|
||||
51
apps/ios/Tests/ShareToAgentDeepLinkTests.swift
Normal file
51
apps/ios/Tests/ShareToAgentDeepLinkTests.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct ShareToAgentDeepLinkTests {
|
||||
@Test func buildMessageIncludesSharedFields() {
|
||||
let payload = SharedContentPayload(
|
||||
title: "Article",
|
||||
url: URL(string: "https://example.com/post")!,
|
||||
text: "Read this")
|
||||
|
||||
let message = ShareToAgentDeepLink.buildMessage(
|
||||
from: payload,
|
||||
instruction: "Summarize and give next steps.")
|
||||
#expect(message.contains("Shared from iOS."))
|
||||
#expect(message.contains("Title: Article"))
|
||||
#expect(message.contains("URL: https://example.com/post"))
|
||||
#expect(message.contains("Text:\nRead this"))
|
||||
#expect(message.contains("Summarize and give next steps."))
|
||||
}
|
||||
|
||||
@Test func buildURLEncodesAgentRoute() {
|
||||
let payload = SharedContentPayload(
|
||||
title: "",
|
||||
url: URL(string: "https://example.com")!,
|
||||
text: nil)
|
||||
|
||||
let url = ShareToAgentDeepLink.buildURL(from: payload)
|
||||
let parsed = url.flatMap { DeepLinkParser.parse($0) }
|
||||
guard case let .agent(agent)? = parsed else {
|
||||
Issue.record("Expected openclaw://agent deep link")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(agent.thinking == "low")
|
||||
#expect(agent.message.contains("https://example.com"))
|
||||
}
|
||||
|
||||
@Test func buildURLReturnsNilWhenPayloadEmpty() {
|
||||
let payload = SharedContentPayload(title: nil, url: nil, text: nil)
|
||||
#expect(ShareToAgentDeepLink.buildURL(from: payload) == nil)
|
||||
}
|
||||
|
||||
@Test func shareInstructionSettingsRoundTrip() {
|
||||
let value = "Focus on booking constraints and alternatives."
|
||||
ShareToAgentSettings.saveDefaultInstruction(value)
|
||||
defer { ShareToAgentSettings.saveDefaultInstruction(nil) }
|
||||
|
||||
#expect(ShareToAgentSettings.loadDefaultInstruction() == value)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,14 @@ targets:
|
||||
OpenClaw:
|
||||
type: application
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Sources
|
||||
dependencies:
|
||||
- target: OpenClawShareExtension
|
||||
embed: true
|
||||
- package: OpenClawKit
|
||||
- package: OpenClawKit
|
||||
product: OpenClawChatUI
|
||||
@@ -66,12 +71,13 @@ targets:
|
||||
exit 1
|
||||
fi
|
||||
swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
|
||||
configFiles:
|
||||
Debug: Config/Signing.xcconfig
|
||||
Release: Config/Signing.xcconfig
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
@@ -80,6 +86,10 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.16"
|
||||
CFBundleVersion: "20260216"
|
||||
UILaunchScreen: {}
|
||||
@@ -108,6 +118,28 @@ targets:
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
|
||||
OpenClawShareExtension:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: ShareExtension
|
||||
dependencies:
|
||||
- package: OpenClawKit
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
path: ShareExtension/Info.plist
|
||||
|
||||
OpenClawTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
|
||||
public let gatewayURLString: String
|
||||
public let token: String?
|
||||
public let password: String?
|
||||
public let sessionKey: String
|
||||
public let deliveryChannel: String?
|
||||
public let deliveryTo: String?
|
||||
|
||||
public init(
|
||||
gatewayURLString: String,
|
||||
token: String?,
|
||||
password: String?,
|
||||
sessionKey: String,
|
||||
deliveryChannel: String? = nil,
|
||||
deliveryTo: String? = nil)
|
||||
{
|
||||
self.gatewayURLString = gatewayURLString
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.sessionKey = sessionKey
|
||||
self.deliveryChannel = deliveryChannel
|
||||
self.deliveryTo = deliveryTo
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShareGatewayRelaySettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static let relayConfigKey = "share.gatewayRelay.config.v1"
|
||||
private static let lastEventKey = "share.gatewayRelay.event.v1"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: self.suiteName) ?? .standard
|
||||
}
|
||||
|
||||
public static func loadConfig() -> ShareGatewayRelayConfig? {
|
||||
guard let data = self.defaults.data(forKey: self.relayConfigKey) else { return nil }
|
||||
return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data)
|
||||
}
|
||||
|
||||
public static func saveConfig(_ config: ShareGatewayRelayConfig) {
|
||||
guard let data = try? JSONEncoder().encode(config) else { return }
|
||||
self.defaults.set(data, forKey: self.relayConfigKey)
|
||||
}
|
||||
|
||||
public static func clearConfig() {
|
||||
self.defaults.removeObject(forKey: self.relayConfigKey)
|
||||
}
|
||||
|
||||
public static func saveLastEvent(_ message: String) {
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
let payload = "[\(timestamp)] \(message)"
|
||||
self.defaults.set(payload, forKey: self.lastEventKey)
|
||||
}
|
||||
|
||||
public static func loadLastEvent() -> String? {
|
||||
let value = self.defaults.string(forKey: self.lastEventKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
public struct SharedContentPayload: Sendable, Equatable {
|
||||
public let title: String?
|
||||
public let url: URL?
|
||||
public let text: String?
|
||||
|
||||
public init(title: String?, url: URL?, text: String?) {
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShareToAgentDeepLink {
|
||||
public static func buildURL(from payload: SharedContentPayload, instruction: String? = nil) -> URL? {
|
||||
let message = self.buildMessage(from: payload, instruction: instruction)
|
||||
guard !message.isEmpty else { return nil }
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = "openclaw"
|
||||
components.host = "agent"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "message", value: message),
|
||||
URLQueryItem(name: "thinking", value: "low"),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
|
||||
public static func buildMessage(from payload: SharedContentPayload, instruction: String? = nil) -> String {
|
||||
let title = self.clean(payload.title)
|
||||
let text = self.clean(payload.text)
|
||||
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
|
||||
|
||||
var lines: [String] = ["Shared from iOS."]
|
||||
if let title, !title.isEmpty {
|
||||
lines.append("Title: \(title)")
|
||||
}
|
||||
if let urlText, !urlText.isEmpty {
|
||||
lines.append("URL: \(urlText)")
|
||||
}
|
||||
if let text, !text.isEmpty {
|
||||
lines.append("Text:\n\(text)")
|
||||
}
|
||||
lines.append(resolvedInstruction)
|
||||
|
||||
let message = lines.joined(separator: "\n\n")
|
||||
return self.limit(message, maxCharacters: 2400)
|
||||
}
|
||||
|
||||
private static func clean(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func limit(_ value: String, maxCharacters: Int) -> String {
|
||||
guard value.count > maxCharacters else { return value }
|
||||
return String(value.prefix(maxCharacters))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
public enum ShareToAgentSettings {
|
||||
private static let suiteName = "group.ai.openclaw.shared"
|
||||
private static let defaultInstructionKey = "share.defaultInstruction"
|
||||
private static let fallbackInstruction = "Please help me with this."
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
public static func loadDefaultInstruction() -> String {
|
||||
let raw = self.defaults.string(forKey: self.defaultInstructionKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let raw, !raw.isEmpty {
|
||||
return raw
|
||||
}
|
||||
return self.fallbackInstruction
|
||||
}
|
||||
|
||||
public static func saveDefaultInstruction(_ value: String?) {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
self.defaults.removeObject(forKey: self.defaultInstructionKey)
|
||||
return
|
||||
}
|
||||
self.defaults.set(trimmed, forKey: self.defaultInstructionKey)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ vi.mock("../../config/sessions.js", () => ({
|
||||
vi.mock("../../routing/session-key.js", () => ({
|
||||
buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
|
||||
normalizeAgentId: vi.fn((id: string) => id),
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock("../commands/agent.js", () => ({
|
||||
}));
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
|
||||
STATE_DIR: "/tmp/openclaw-state",
|
||||
}));
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
updateSessionStore: vi.fn(),
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore } from "../config/sessions.js";
|
||||
import { updateSessionStore } from "../config/sessions.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { parseMessageWithAttachments } from "./chat-attachments.js";
|
||||
import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
|
||||
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
|
||||
import {
|
||||
loadSessionEntry,
|
||||
@@ -178,6 +185,45 @@ function queueSessionStoreTouch(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFallbackDeliveryRoute(params: {
|
||||
storePath: LoadedSessionEntry["storePath"];
|
||||
preferredChannel?: string;
|
||||
}): { channel?: string; to?: string } {
|
||||
const { storePath, preferredChannel } = params;
|
||||
if (!storePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const targetChannel = preferredChannel?.trim().toLowerCase();
|
||||
const store = loadSessionStore(storePath);
|
||||
const candidates = Object.values(store)
|
||||
.filter((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
const channel = typeof entry.lastChannel === "string" ? entry.lastChannel.trim() : "";
|
||||
const to = typeof entry.lastTo === "string" ? entry.lastTo.trim() : "";
|
||||
if (!channel || !to) {
|
||||
return false;
|
||||
}
|
||||
if (targetChannel && channel.toLowerCase() !== targetChannel) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
const winner = candidates[0];
|
||||
if (!winner) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
channel: typeof winner.lastChannel === "string" ? winner.lastChannel.trim() : undefined,
|
||||
to: typeof winner.lastTo === "string" ? winner.lastTo.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
|
||||
let payload: unknown;
|
||||
try {
|
||||
@@ -193,6 +239,35 @@ function parseSessionKeyFromPayloadJSON(payloadJSON: string): string | null {
|
||||
return sessionKey.length > 0 ? sessionKey : null;
|
||||
}
|
||||
|
||||
async function sendReceiptAck(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
deps: NodeEventContext["deps"];
|
||||
sessionKey: string;
|
||||
channel: string;
|
||||
to: string;
|
||||
text: string;
|
||||
}) {
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
cfg: params.cfg,
|
||||
mode: "explicit",
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
throw new Error(String(resolved.error));
|
||||
}
|
||||
const agentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg });
|
||||
await deliverOutboundPayloads({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
to: resolved.to,
|
||||
payloads: [{ text: params.text }],
|
||||
agentId,
|
||||
bestEffort: true,
|
||||
deps: createOutboundSendDeps(params.deps),
|
||||
});
|
||||
}
|
||||
|
||||
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
|
||||
switch (evt.event) {
|
||||
case "voice.transcript": {
|
||||
@@ -273,6 +348,14 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
sessionKey?: string | null;
|
||||
thinking?: string | null;
|
||||
deliver?: boolean;
|
||||
attachments?: Array<{
|
||||
type?: string;
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
content?: unknown;
|
||||
}> | null;
|
||||
receipt?: boolean;
|
||||
receiptText?: string | null;
|
||||
to?: string | null;
|
||||
channel?: string | null;
|
||||
timeoutSeconds?: number | null;
|
||||
@@ -284,7 +367,23 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const message = (link?.message ?? "").trim();
|
||||
let message = (link?.message ?? "").trim();
|
||||
const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(
|
||||
link?.attachments ?? undefined,
|
||||
);
|
||||
let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
||||
if (normalizedAttachments.length > 0) {
|
||||
try {
|
||||
const parsed = await parseMessageWithAttachments(message, normalizedAttachments, {
|
||||
maxBytes: 5_000_000,
|
||||
log: ctx.logGateway,
|
||||
});
|
||||
message = parsed.message.trim();
|
||||
images = parsed.images;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
@@ -293,9 +392,13 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
}
|
||||
|
||||
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
|
||||
const channel = normalizeChannelId(channelRaw) ?? undefined;
|
||||
const to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
|
||||
const deliver = Boolean(link?.deliver) && Boolean(channel);
|
||||
let channel = normalizeChannelId(channelRaw) ?? undefined;
|
||||
let to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
|
||||
const deliver = Boolean(link?.deliver);
|
||||
const wantsReceipt = Boolean(link?.receipt);
|
||||
const receiptTextRaw = typeof link?.receiptText === "string" ? link.receiptText.trim() : "";
|
||||
const receiptText =
|
||||
receiptTextRaw || "Just received your iOS share + request, working on it.";
|
||||
|
||||
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
||||
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
|
||||
@@ -305,9 +408,53 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
await touchSessionStore({ cfg, sessionKey, storePath, canonicalKey, entry, sessionId, now });
|
||||
|
||||
if (deliver && (!channel || !to)) {
|
||||
const entryChannel =
|
||||
typeof entry?.lastChannel === "string"
|
||||
? normalizeChannelId(entry.lastChannel)
|
||||
: undefined;
|
||||
const entryTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : "";
|
||||
if (!channel && entryChannel) {
|
||||
channel = entryChannel;
|
||||
}
|
||||
if (!to && entryTo) {
|
||||
to = entryTo;
|
||||
}
|
||||
}
|
||||
if (deliver && (!channel || !to)) {
|
||||
const fallback = resolveFallbackDeliveryRoute({
|
||||
storePath,
|
||||
preferredChannel: channel ?? cfg.channels?.default ?? "telegram",
|
||||
});
|
||||
if (!channel && fallback.channel) {
|
||||
channel = normalizeChannelId(fallback.channel) ?? channel;
|
||||
}
|
||||
if (!to && fallback.to) {
|
||||
to = fallback.to;
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsReceipt && channel && to) {
|
||||
void sendReceiptAck({
|
||||
cfg,
|
||||
deps: ctx.deps,
|
||||
sessionKey: canonicalKey,
|
||||
channel,
|
||||
to,
|
||||
text: receiptText,
|
||||
}).catch((err) => {
|
||||
ctx.logGateway.warn(`agent receipt failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
});
|
||||
} else if (wantsReceipt) {
|
||||
ctx.logGateway.warn(
|
||||
`agent receipt skipped node=${nodeId}: missing delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
|
||||
);
|
||||
}
|
||||
|
||||
void agentCommand(
|
||||
{
|
||||
message,
|
||||
images,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
thinking: link?.thinking ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user