From 049c88877d2a6320843fa0235aa620e4fea518ac Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Tue, 17 Feb 2026 19:47:01 +0000 Subject: [PATCH] feat: share to openclaw ios app --- apps/ios/ShareExtension/Info.plist | 9 +- .../ShareExtension/ShareViewController.swift | 106 ++++++++++++++++-- src/gateway/server-node-events.ts | 27 ++++- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0f0c5ed5fa5..3ffdfd4841b 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -25,7 +25,14 @@ NSExtensionAttributes NSExtensionActivationRule - TRUEPREDICATE + + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionPointIdentifier com.apple.share-services diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index 600122e2715..dce2e1d9034 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -5,6 +5,18 @@ 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() @@ -12,6 +24,7 @@ final class ShareViewController: UIViewController { private let cancelButton = UIButton(type: .system) private var didPrepareDraft = false private var isSending = false + private var pendingAttachments: [ShareAttachment] = [] override func viewDidLoad() { super.viewDidLoad() @@ -73,9 +86,11 @@ final class ShareViewController: UIViewController { ShareGatewayRelaySettings.saveLastEvent("Share opened.") self.showStatus("Preparing share…") self.logger.info("share begin trace=\(traceId, privacy: .public)") - let payload = await self.extractSharedContent() + 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)" + "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 { @@ -120,8 +135,9 @@ final class ShareViewController: UIViewController { self.showStatus("Sending to OpenClaw gateway…") ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…") do { - try await self.sendMessageToGateway(trimmed) - ShareGatewayRelaySettings.saveLastEvent("Sent to gateway (\(trimmed.count) chars).") + 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) @@ -138,7 +154,7 @@ final class ShareViewController: UIViewController { } } - private func sendMessageToGateway(_ message: String) async throws { + private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws { guard let config = ShareGatewayRelaySettings.loadConfig() else { throw NSError( domain: "OpenClawShare", @@ -213,6 +229,7 @@ final class ShareViewController: UIViewController { var sessionKey: String? var thinking: String var deliver: Bool + var attachments: [ShareAttachment]? var receipt: Bool var receiptText: String? var to: String? @@ -226,6 +243,7 @@ final class ShareViewController: UIViewController { 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, @@ -316,9 +334,11 @@ final class ShareViewController: UIViewController { return cleaned.isEmpty ? nil : cleaned } - private func extractSharedContent() async -> SharedContentPayload { + private func extractSharedContent() async -> ExtractedShareContent { guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { - return SharedContentPayload(title: nil, url: nil, text: nil) + return ExtractedShareContent( + payload: SharedContentPayload(title: nil, url: nil, text: nil), + attachments: []) } var title: String? @@ -328,6 +348,8 @@ final class ShareViewController: UIViewController { var videoCount = 0 var fileCount = 0 var unknownCount = 0 + var attachments: [ShareAttachment] = [] + let maxImageAttachments = 3 for item in items { if title == nil { @@ -345,6 +367,11 @@ final class ShareViewController: UIViewController { 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) { @@ -364,7 +391,62 @@ final class ShareViewController: UIViewController { _ = fileCount _ = unknownCount - return SharedContentPayload(title: title, url: sharedURL, text: sharedText) + 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 + var data = rawData + var mimeType: String + var fileExt: String + if let image = UIImage(data: rawData), let normalized = self.normalizedJPEGData(from: image, maxBytes: maxBytes) { + data = normalized + mimeType = "image/jpeg" + fileExt = "jpg" + } else { + let utType = UTType(imageUTI) + mimeType = utType?.preferredMIMEType ?? "application/octet-stream" + fileExt = utType?.preferredFilenameExtension ?? "bin" + if data.count > maxBytes { + data = Data(data.prefix(maxBytes)) + } + } + + return ShareAttachment( + type: "image", + mimeType: mimeType, + fileName: "shared-image-\(index + 1).\(fileExt)", + 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 Data(fallback.prefix(maxBytes)) } private func loadURL(from provider: NSItemProvider) async -> URL? { @@ -444,4 +526,12 @@ final class ShareViewController: UIViewController { } } } + + 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/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 35ac3462d05..dafadd06380 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -13,6 +13,8 @@ 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 { loadSessionEntry, pruneLegacyStoreKeys, @@ -346,6 +348,12 @@ 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; @@ -359,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; } @@ -430,6 +454,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt void agentCommand( { message, + images, sessionId, sessionKey: canonicalKey, thinking: link?.thinking ?? undefined,