anydrop/web/ios/App/AnyDropShare/ShareViewController.swift
ordinarthur 9674b19590
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m14s
feat(ios): Capacitor shell + Share Extension scaffold
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>
2026-04-20 15:59:51 +02:00

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)
}
}