mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 14:18:39 +00:00
feat: share to openclaw ios app (#19424)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 0a7ab8589a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
524
apps/ios/ShareExtension/ShareViewController.swift
Normal file
524
apps/ios/ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,524 @@
|
||||
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 text = error.localizedDescription.lowercased()
|
||||
let expectsLegacyClientId = text.contains("invalid connect params") && text.contains("/client/id")
|
||||
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 params = AgentRequestPayload(
|
||||
message: message,
|
||||
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,
|
||||
channel: config.deliveryChannel,
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user