Files
openclaw/apps/ios/ShareExtension/ShareViewController.swift
lin-hongkuan 522e253c53 fix(ios): resize oversized shared images (#103860)
* fix(ios): resize oversized shared images

* fix(ios): satisfy SwiftFormat await rules

* fix(ios): integrate share image processing

* fix(ios): type share attachment failures

---------

Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 15:16:45 -07:00

485 lines
20 KiB
Swift

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
}
/// Keeps the encoded payload with its preview so the compose card can show
/// thumbnails without re-decoding base64 content.
private struct LoadedAttachment {
var payload: ShareAttachment
var preview: UIImage
}
private struct ExtractedShareContent {
var payload: SharedContentPayload
var attachments: [LoadedAttachment]
var attachmentError: ShareImageProcessor.ProcessError?
}
private let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ShareExtension")
private let composeView = ShareComposeView()
private var didPrepareDraft = false
private var isSending = false
private var pendingAttachments: [ShareAttachment] = []
private var attachmentError: ShareImageProcessor.ProcessError?
override func loadView() {
self.view = self.composeView
}
override func viewDidLoad() {
super.viewDidLoad()
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
self.composeView.onSend = { [weak self] in self?.handleSendTap() }
self.composeView.onCancel = { [weak self] in
self?.extensionContext?.completeRequest(returningItems: nil)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !self.didPrepareDraft else { return }
self.didPrepareDraft = true
Task { await self.prepareDraft() }
}
private func prepareDraft() async {
let traceId = UUID().uuidString
ShareGatewayRelaySettings.saveLastEvent("Share opened.")
self.composeView.apply(.preparing)
self.logger.info("share begin trace=\(traceId, privacy: .public)")
let extracted = await self.extractSharedContent()
let payload = extracted.payload
self.pendingAttachments = extracted.attachments.map(\.payload)
self.attachmentError = extracted.attachmentError
self.logger.info("share payload trace=\(traceId, privacy: .public)")
self.logger.info(
"share payload title=\(payload.title?.count ?? 0) text=\(payload.text?.count ?? 0)")
self.logger.info(
"share attachments hasURL=\(payload.url != nil) images=\(self.pendingAttachments.count)")
let message = ShareDraftComposer.compose(from: payload)
self.composeView.setDraft(message)
self.composeView.setAttachmentPreviews(extracted.attachments.map(\.preview))
self.composeView.focusDraft()
if let attachmentError = extracted.attachmentError {
ShareGatewayRelaySettings.saveLastEvent("Share blocked: image processing failed.")
self.composeView.apply(.blocked(self.imageProcessingErrorMessage(attachmentError)))
} else if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.composeView.apply(.ready)
ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.")
} else {
self.composeView.apply(.ready)
ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.")
}
}
private func handleSendTap() {
guard !self.isSending else { return }
Task { await self.sendCurrentDraft() }
}
private func sendCurrentDraft() async {
if let attachmentError = self.attachmentError {
self.composeView.apply(.blocked(self.imageProcessingErrorMessage(attachmentError)))
return
}
let trimmed = self.composeView.draftText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.")
self.composeView.apply(.failed(NSLocalizedString(
"Message is empty.",
comment: "Share extension empty message status")))
return
}
self.isSending = true
self.composeView.apply(.sending)
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.composeView.apply(.sent)
UINotificationFeedbackGenerator().notificationOccurred(.success)
// Give the success state a visible beat before the sheet dismisses.
try? await Task.sleep(for: .milliseconds(600))
self.extensionContext?.completeRequest(returningItems: nil)
} catch {
self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)")
ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)")
self.isSending = false
self.composeView.apply(.failed(String(
format: NSLocalizedString("Send failed: %@", comment: "Share extension failure status"),
error.localizedDescription)))
}
}
private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws {
guard let config = ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth() else {
throw NSError(
domain: "OpenClawShare",
code: 10,
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"OpenClaw is not connected to a gateway yet.",
comment: "Share extension missing gateway error"),
])
}
guard let url = URL(string: config.gatewayURLString) else {
throw NSError(
domain: "OpenClawShare",
code: 11,
userInfo: [
NSLocalizedDescriptionKey: NSLocalizedString(
"Invalid saved gateway URL.",
comment: "Share extension invalid gateway error"),
])
}
let gateway = GatewayNodeSession()
defer {
Task { await gateway.disconnect() }
}
let makeOptions: (String) -> GatewayConnectOptions = { clientId in
GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true,
allowStoredDeviceAuth: config.gatewayStableID != nil,
deviceAuthGatewayID: config.gatewayStableID)
}
do {
try await gateway.connect(
url: url,
credentials: GatewayNodeSessionCredentials(
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,
credentials: GatewayNodeSessionCredentials(
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 extractSharedContent() async -> ExtractedShareContent {
guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else {
return ExtractedShareContent(
payload: SharedContentPayload(title: nil, url: nil, text: nil),
attachments: [],
attachmentError: nil)
}
var title: String?
var sharedURL: URL?
var sharedText: String?
var attributedContentText: String?
var attachments: [LoadedAttachment] = []
var attachmentError: ShareImageProcessor.ProcessError?
let maxImageAttachments = 3
for item in items {
if title == nil {
title = item.attributedTitle?.string
}
if attributedContentText == nil {
attributedContentText = 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) {
if attachments.count < maxImageAttachments, attachmentError == nil {
do {
let attachment = try await self.loadImageAttachment(
from: provider,
index: attachments.count)
attachments.append(attachment)
} catch let error as ShareImageProcessor.ProcessError {
attachmentError = error
} catch {
attachmentError = .encodeFailed
}
}
}
}
}
// Share hosts often mirror provider text in attributedContentText.
// Preserve distinct content as the historical title, but do not duplicate provider data.
let supplementalTitle = SharePayloadNormalizer.distinctAttributedText(
attributedContentText,
sharedText: sharedText,
sharedURL: sharedURL)
return ExtractedShareContent(
payload: SharedContentPayload(title: title ?? supplementalTitle, url: sharedURL, text: sharedText),
attachments: attachments,
attachmentError: attachmentError)
}
private func loadImageAttachment(from provider: NSItemProvider, index: Int) async throws -> LoadedAttachment {
let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier
guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else {
throw ShareImageProcessor.ProcessError.invalidImage
}
let data = try await Task.detached(priority: .userInitiated) {
try ShareImageProcessor.processForUpload(data: rawData)
}.value
guard let image = UIImage(data: data) else {
throw ShareImageProcessor.ProcessError.invalidImage
}
return await LoadedAttachment(
payload: ShareAttachment(
type: "image",
mimeType: "image/jpeg",
fileName: "shared-image-\(index + 1).jpg",
content: data.base64EncodedString()),
preview: self.boundedPreview(from: image))
}
private func imageProcessingErrorMessage(_: ShareImageProcessor.ProcessError) -> String {
NSLocalizedString(
"The shared image could not be prepared.",
comment: "Share extension image processing failure")
}
/// Previews are retained for the sheet's lifetime; keep them bounded so
/// three full-resolution photos cannot blow the extension memory cap.
/// Never falls back to the full-size image.
private func boundedPreview(from image: UIImage) async -> UIImage {
let maxPixels: CGFloat = 336
if let thumbnail = await image.byPreparingThumbnail(ofSize: CGSize(width: maxPixels, height: maxPixels)) {
return thumbnail
}
let longestSide = max(image.size.width * image.scale, image.size.height * image.scale, 1)
let scale = min(maxPixels / longestSide, 1)
let target = CGSize(
width: max(image.size.width * image.scale * scale, 1),
height: max(image.size.height * image.scale * scale, 1))
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
return UIGraphicsImageRenderer(size: target, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
}
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 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 = SharePayloadNormalizer.webURL(from: text)
{
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)
}
}
}
}