All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m14s
Phase 4 (mobile) kickoff. Ships the native iOS wrapper that lets users share photos/videos from the iOS Share Sheet into AnyDrop — the one thing the PWA alone cannot do on iOS because Apple still ignores the Web Share Target API. Architecture: - web/ios/ — Capacitor-generated Xcode project pointing the WKWebView at https://anydrop.arthurbarre.fr (real web app; only native code is the share-in path, so no "thin wrapper" App Store concern). - AnyDropShare (Share Extension target) — stashes selected items into an App Group shared container then opens anydrop://share. - SharedInboxPlugin (custom Capacitor plugin) — drains that container from JS after the URL fires, returning base64 blobs. - web/src/lib/nativeShare.ts — bridge that rehydrates File objects and routes them into the existing /share page flow (same one Android uses). Xcode-side target registration + signing isn't scriptable; runbook is in docs/ios-setup.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
180 lines
5.9 KiB
Swift
180 lines
5.9 KiB
Swift
//
|
|
// ShareViewController.swift
|
|
// AnyDropShare
|
|
//
|
|
// Share Extension that receives photos/videos (and other files) from the
|
|
// iOS Share Sheet, stashes them into the App Group shared container, and
|
|
// opens the main AnyDrop app via the custom URL scheme so the webview can
|
|
// consume them.
|
|
//
|
|
|
|
import UIKit
|
|
import Social
|
|
import MobileCoreServices
|
|
import UniformTypeIdentifiers
|
|
|
|
private let appGroupId = "group.fr.arthurbarre.anydrop"
|
|
|
|
class ShareViewController: UIViewController {
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = UIColor(white: 0.96, alpha: 1.0)
|
|
processSharedItems()
|
|
}
|
|
|
|
private func processSharedItems() {
|
|
guard
|
|
let extensionItems = extensionContext?.inputItems as? [NSExtensionItem]
|
|
else {
|
|
finish()
|
|
return
|
|
}
|
|
|
|
let group = DispatchGroup()
|
|
var savedFiles: [[String: String]] = []
|
|
let savedFilesLock = NSLock()
|
|
|
|
for item in extensionItems {
|
|
guard let attachments = item.attachments else { continue }
|
|
for provider in attachments {
|
|
group.enter()
|
|
loadFile(provider: provider) { fileInfo in
|
|
if let info = fileInfo {
|
|
savedFilesLock.lock()
|
|
savedFiles.append(info)
|
|
savedFilesLock.unlock()
|
|
}
|
|
group.leave()
|
|
}
|
|
}
|
|
}
|
|
|
|
group.notify(queue: .main) { [weak self] in
|
|
self?.writeManifest(files: savedFiles)
|
|
self?.openHostApp()
|
|
self?.finish()
|
|
}
|
|
}
|
|
|
|
private func loadFile(
|
|
provider: NSItemProvider,
|
|
completion: @escaping ([String: String]?) -> Void
|
|
) {
|
|
let candidates: [UTType] = [.image, .movie, .audio, .pdf, .fileURL, .data]
|
|
let match = candidates.first { provider.hasItemConformingToTypeIdentifier($0.identifier) }
|
|
guard let utType = match else { completion(nil); return }
|
|
|
|
provider.loadItem(forTypeIdentifier: utType.identifier, options: nil) { data, _ in
|
|
self.persist(item: data, hintType: utType, completion: completion)
|
|
}
|
|
}
|
|
|
|
private func persist(
|
|
item: Any?,
|
|
hintType: UTType,
|
|
completion: @escaping ([String: String]?) -> Void
|
|
) {
|
|
guard let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupId
|
|
) else {
|
|
completion(nil); return
|
|
}
|
|
|
|
let inbox = container.appendingPathComponent("shared-inbox", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
|
|
|
|
let uuid = UUID().uuidString
|
|
|
|
func write(data: Data, ext: String, mime: String, name: String) {
|
|
let fileName = name.isEmpty ? "\(uuid).\(ext)" : name
|
|
let dest = inbox.appendingPathComponent("\(uuid)-\(fileName)")
|
|
do {
|
|
try data.write(to: dest)
|
|
completion([
|
|
"id": uuid,
|
|
"path": dest.lastPathComponent,
|
|
"name": fileName,
|
|
"mime": mime,
|
|
"size": String(data.count),
|
|
])
|
|
} catch {
|
|
completion(nil)
|
|
}
|
|
}
|
|
|
|
if let url = item as? URL {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
let name = url.lastPathComponent
|
|
let ext = url.pathExtension.isEmpty ? hintType.preferredFilenameExtension ?? "bin" : url.pathExtension
|
|
let mime = hintType.preferredMIMEType ?? mimeFromPath(url: url)
|
|
write(data: data, ext: ext, mime: mime, name: name)
|
|
} catch {
|
|
completion(nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let image = item as? UIImage, let data = image.jpegData(compressionQuality: 0.95) {
|
|
write(data: data, ext: "jpg", mime: "image/jpeg", name: "")
|
|
return
|
|
}
|
|
|
|
if let data = item as? Data {
|
|
let ext = hintType.preferredFilenameExtension ?? "bin"
|
|
let mime = hintType.preferredMIMEType ?? "application/octet-stream"
|
|
write(data: data, ext: ext, mime: mime, name: "")
|
|
return
|
|
}
|
|
|
|
completion(nil)
|
|
}
|
|
|
|
private func mimeFromPath(url: URL) -> String {
|
|
if let type = UTType(filenameExtension: url.pathExtension),
|
|
let mime = type.preferredMIMEType {
|
|
return mime
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
private func writeManifest(files: [[String: String]]) {
|
|
guard let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupId
|
|
) else { return }
|
|
|
|
let manifest: [String: Any] = [
|
|
"createdAt": Date().timeIntervalSince1970,
|
|
"files": files,
|
|
]
|
|
let path = container.appendingPathComponent("shared-inbox").appendingPathComponent("manifest.json")
|
|
if let data = try? JSONSerialization.data(withJSONObject: manifest, options: []) {
|
|
try? data.write(to: path)
|
|
}
|
|
}
|
|
|
|
private func openHostApp() {
|
|
guard let url = URL(string: "anydrop://share") else { return }
|
|
|
|
// Walk up responder chain to find something that can open URLs.
|
|
var responder: UIResponder? = self
|
|
while let r = responder {
|
|
if let application = r as? UIApplication {
|
|
application.open(url, options: [:], completionHandler: nil)
|
|
return
|
|
}
|
|
if let selector = NSSelectorFromString("openURL:") as Selector?,
|
|
r.responds(to: selector) {
|
|
_ = r.perform(selector, with: url)
|
|
return
|
|
}
|
|
responder = r.next
|
|
}
|
|
}
|
|
|
|
private func finish() {
|
|
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|