iOS Share Extension: pick the destination inside the share sheet

SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via
a Keychain group, upload on a background session that outlives the extension
and is finished by the app. The inbox + notification hand-off remains as the
fallback (no account / 'choose later'). Shared code moves to ios/Shared.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 19:36:04 -04:00
co-authored by Claude Sonnet 5.5
parent 525504bc47
commit afc9b90963
19 changed files with 841 additions and 154 deletions
+67
View File
@@ -0,0 +1,67 @@
import Foundation
/// What a background upload/download task is, stored in the task's
/// `taskDescription` so it's still known if the app was relaunched by the
/// system to deliver the result - possibly for a task the Share Extension
/// started. Compiled into both targets.
struct TransferTaskInfo: Codable {
enum Kind: String, Codable { case upload, download }
let kind: Kind
let batch: String
let name: String
let remoteFolder: String?
/// A file to delete once the task ends (the upload's staged copy).
let stagedPath: String?
}
/// Running totals for one "upload these 3 files" / "download these 2 files"
/// request, so the one summary notification (and the Dart-side "folder
/// changed" event) fires exactly once, on the last file.
struct TransferBatch: Codable {
let kind: TransferTaskInfo.Kind
let remoteFolder: String?
let total: Int
var succeeded: Int
var failed: Int
var finished: Int { succeeded + failed }
}
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
/// process's own: the Share Extension starts an upload batch, and the app -
/// a different process, possibly relaunched in the background - finishes
/// counting it.
enum TransferBatchStore {
private static let lock = NSLock()
private static var defaults: UserDefaults {
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
}
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
static func save(_ batch: TransferBatch, id: String) {
lock.lock()
defer { lock.unlock() }
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
}
/// Counts one file's outcome. Returns the batch when that was its last
/// file (and forgets it), nil while others are still running.
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
lock.lock()
defer { lock.unlock() }
let storageKey = key(info.batch)
var batch =
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
if success { batch.succeeded += 1 } else { batch.failed += 1 }
if batch.finished >= batch.total {
defaults.removeObject(forKey: storageKey)
return batch
}
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
return nil
}
}