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 expectsLegacyClientId = self.shouldRetryWithLegacyClientId(error) 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 deliveryChannel = config.deliveryChannel?.trimmingCharacters(in: .whitespacesAndNewlines) let deliveryTo = config.deliveryTo?.trimmingCharacters(in: .whitespacesAndNewlines) let canDeliverToRoute = (deliveryChannel?.isEmpty == false) && (deliveryTo?.isEmpty == false) let params = AgentRequestPayload( message: message, sessionKey: config.sessionKey, thinking: "low", deliver: canDeliverToRoute, attachments: attachments.isEmpty ? nil : attachments, receipt: canDeliverToRoute, receiptText: canDeliverToRoute ? "Just received your iOS share + request, working on it." : nil, to: canDeliverToRoute ? deliveryTo : nil, channel: canDeliverToRoute ? deliveryChannel : nil, timeoutSeconds: nil, key: UUID().uuidString) let data = try JSONEncoder().encode(params) guard let json = String(data: data, encoding: .utf8) else { throw NSError( domain: "OpenClawShare", code: 12, userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."]) } struct NodeEventParams: Codable { var event: String var payloadJSON: String } let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json)) guard let nodeEventParams = String(data: eventData, encoding: .utf8) else { throw NSError( domain: "OpenClawShare", code: 13, userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."]) } _ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25) } private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool { if let gatewayError = error as? GatewayResponseError { let code = gatewayError.code.lowercased() let message = gatewayError.message.lowercased() let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? "" let mentionsClientIdPath = message.contains("/client/id") || message.contains("client id") || pathValue.contains("/client/id") let isInvalidConnectParams = (code.contains("invalid") && code.contains("connect")) || message.contains("invalid connect params") if isInvalidConnectParams && mentionsClientIdPath { return true } } let text = error.localizedDescription.lowercased() return text.contains("invalid connect params") && (text.contains("/client/id") || text.contains("client id")) } private func showStatus(_ text: String) { DispatchQueue.main.async { let label: UILabel if let existing = self.statusLabel { label = existing } else { let newLabel = UILabel() newLabel.translatesAutoresizingMaskIntoConstraints = false newLabel.numberOfLines = 0 newLabel.textAlignment = .center newLabel.font = .preferredFont(forTextStyle: .body) newLabel.textColor = .label newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92) newLabel.layer.cornerRadius = 12 newLabel.clipsToBounds = true newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) self.view.addSubview(newLabel) NSLayoutConstraint.activate([ newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18), newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18), newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10), ]) self.statusLabel = newLabel label = newLabel } label.text = " \(text) " } } private func composeDraft(from payload: SharedContentPayload) -> String { var lines: [String] = [] let title = self.sanitizeDraftFragment(payload.title) let text = self.sanitizeDraftFragment(payload.text) let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if let title, !title.isEmpty { lines.append(title) } if let text, !text.isEmpty { lines.append(text) } if !url.isEmpty { lines.append(url) } return lines.joined(separator: "\n\n") } private func sanitizeDraftFragment(_ raw: String?) -> String? { guard let raw else { return nil } let banned = [ "shared from ios.", "text:", "shared attachment(s):", "please help me with this.", "please help me with this.w", ] let cleanedLines = raw .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { line in guard !line.isEmpty else { return false } let lowered = line.lowercased() return !banned.contains { lowered == $0 || lowered.hasPrefix($0) } } let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) return cleaned.isEmpty ? nil : cleaned } private func extractSharedContent() async -> ExtractedShareContent { guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { return ExtractedShareContent( payload: SharedContentPayload(title: nil, url: nil, text: nil), attachments: []) } var title: String? var sharedURL: URL? var sharedText: String? var imageCount = 0 var videoCount = 0 var fileCount = 0 var unknownCount = 0 var attachments: [ShareAttachment] = [] let maxImageAttachments = 3 for item in items { if title == nil { title = item.attributedTitle?.string ?? item.attributedContentText?.string } for provider in item.attachments ?? [] { if sharedURL == nil { sharedURL = await self.loadURL(from: provider) } if sharedText == nil { sharedText = await self.loadText(from: provider) } if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { imageCount += 1 if attachments.count < maxImageAttachments, let attachment = await self.loadImageAttachment(from: provider, index: attachments.count) { attachments.append(attachment) } } else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { videoCount += 1 } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { fileCount += 1 } else { unknownCount += 1 } } } _ = imageCount _ = videoCount _ = fileCount _ = unknownCount return ExtractedShareContent( payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText), attachments: attachments) } private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? { let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else { return nil } let maxBytes = 5_000_000 guard let image = UIImage(data: rawData), let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes) else { return nil } return ShareAttachment( type: "image", mimeType: "image/jpeg", fileName: "shared-image-\(index + 1).jpg", content: data.base64EncodedString()) } private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? { for identifier in provider.registeredTypeIdentifiers { guard let utType = UTType(identifier) else { continue } if utType.conforms(to: .image) { return identifier } } return nil } private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? { var quality: CGFloat = 0.9 while quality >= 0.4 { if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes { return data } quality -= 0.1 } guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil } if fallback.count <= maxBytes { return fallback } return nil } private func loadURL(from provider: NSItemProvider) async -> URL? { if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { if let url = await self.loadURLValue( from: provider, typeIdentifier: UTType.url.identifier) { return url } } if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), url.scheme != nil { return url } } return nil } private func loadText(from provider: NSItemProvider) async -> String? { if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) { return text } } if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) { return url.absoluteString } } return nil } private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? { await withCheckedContinuation { continuation in provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in if let url = item as? URL { continuation.resume(returning: url) return } if let str = item as? String, let url = URL(string: str) { continuation.resume(returning: url) return } if let ns = item as? NSString, let url = URL(string: ns as String) { continuation.resume(returning: url) return } continuation.resume(returning: nil) } } } private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? { await withCheckedContinuation { continuation in provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in if let text = item as? String { continuation.resume(returning: text) return } if let text = item as? NSString { continuation.resume(returning: text as String) return } if let text = item as? NSAttributedString { continuation.resume(returning: text.string) return } continuation.resume(returning: nil) } } } private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? { await withCheckedContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in continuation.resume(returning: data) } } } }