mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 05:36:10 +00:00
* 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>
485 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|