From bfc9736366561aa8ba87ee43cd5a2b195195892a Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:08:50 +0000 Subject: [PATCH] feat: share to openclaw ios app (#19424) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 0a7ab8589ac23d0743d4377683d60601a8c19e61 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- .gitignore | 3 + CHANGELOG.md | 1 + apps/ios/LocalSigning.xcconfig.example | 12 + apps/ios/ShareExtension/Info.plist | 43 ++ .../ShareExtension/ShareViewController.swift | 524 ++++++++++++++++++ apps/ios/Signing.xcconfig | 13 + apps/ios/Sources/Info.plist | 2 - apps/ios/Sources/Model/NodeAppModel.swift | 144 ++++- .../Onboarding/OnboardingWizardView.swift | 70 ++- apps/ios/Sources/RootCanvas.swift | 3 + apps/ios/Sources/Settings/SettingsTab.swift | 192 ++++--- .../ios/Tests/ShareToAgentDeepLinkTests.swift | 51 ++ apps/ios/project.yml | 40 +- .../ShareGatewayRelaySettings.swift | 62 +++ .../OpenClawKit/ShareToAgentDeepLink.swift | 62 +++ .../OpenClawKit/ShareToAgentSettings.swift | 29 + .../isolated-agent/run.skill-filter.test.ts | 1 + src/gateway/server-node-events.test.ts | 1 + src/gateway/server-node-events.ts | 155 +++++- 19 files changed, 1300 insertions(+), 108 deletions(-) create mode 100644 apps/ios/LocalSigning.xcconfig.example create mode 100644 apps/ios/ShareExtension/Info.plist create mode 100644 apps/ios/ShareExtension/ShareViewController.swift create mode 100644 apps/ios/Signing.xcconfig create mode 100644 apps/ios/Tests/ShareToAgentDeepLinkTests.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift diff --git a/.gitignore b/.gitignore index 162f2fb6ca..2ee577593b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ USER.md !.agent/workflows/ /local/ package-lock.json + +# Local iOS signing overrides +apps/ios/LocalSigning.xcconfig diff --git a/CHANGELOG.md b/CHANGELOG.md index 195f42e1de..0cb71018bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example new file mode 100644 index 0000000000..6549213108 --- /dev/null +++ b/apps/ios/LocalSigning.xcconfig.example @@ -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 = diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist new file mode 100644 index 0000000000..3ffdfd4841 --- /dev/null +++ b/apps/ios/ShareExtension/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + OpenClaw Share + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 2026.2.16 + CFBundleVersion + 20260216 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000000..0f720f16a9 --- /dev/null +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -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) + } + } + } +} diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig new file mode 100644 index 0000000000..c11f974e9e --- /dev/null +++ b/apps/ios/Signing.xcconfig @@ -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" diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 3182e43d30..d56fc5b658 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -18,8 +18,6 @@ $(PRODUCT_NAME) CFBundlePackageType APPL - CFBundleShortVersionString - 2026.2.16 CFBundleVersion 20260216 NSAppTransportSecurity diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5b59af1585..d1f136d3b4 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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: @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(_ 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() diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index cbe9db2575..f2d2cca635 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -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 ") { - UIPasteboard.general.string = "openclaw devices approve " - } + 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 `)\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( diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 50b54e9dd8..70ba9cdb96 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -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) diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 915c332554..7825b45cb8 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -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, + 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 { diff --git a/apps/ios/Tests/ShareToAgentDeepLinkTests.swift b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift new file mode 100644 index 0000000000..4ea178ecfa --- /dev/null +++ b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift @@ -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) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 8196c0f437..19913a5041 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift new file mode 100644 index 0000000000..7b4c3864b3 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift @@ -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 + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift new file mode 100644 index 0000000000..08f0623433 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift @@ -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)) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift new file mode 100644 index 0000000000..9034dcfe1b --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift @@ -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) + } +} diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 0cc379461f..52bc79b8c6 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -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", () => ({ diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 07525311a0..972159d43f 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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(), diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 267d53f7ec..05d2d59dc8 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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; + 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,