Files
openclaw/apps/ios/ShareExtension/ShareViewController.swift
2026-02-17 23:47:34 +01:00

549 lines
22 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
}
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)
}
}
}
}