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:
co-authored by
Claude Sonnet 5.5
parent
525504bc47
commit
afc9b90963
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// A folder in the account's WebDAV tree. [path] is relative to the user's
|
||||
/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root.
|
||||
struct DavFolder: Equatable {
|
||||
let name: String
|
||||
let path: String
|
||||
}
|
||||
|
||||
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||
/// networking), so it's unit-tested. Compiled into both targets.
|
||||
enum DavFolderParser {
|
||||
private struct Response {
|
||||
var href = ""
|
||||
var isCollection = false
|
||||
}
|
||||
|
||||
private final class Delegate: NSObject, XMLParserDelegate {
|
||||
var responses: [Response] = []
|
||||
private var current: Response?
|
||||
private var text = ""
|
||||
|
||||
private func local(_ name: String) -> String {
|
||||
name.split(separator: ":").last.map(String.init) ?? name
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
switch local(elementName) {
|
||||
case "response": current = Response()
|
||||
case "href": text = ""
|
||||
case "collection": current?.isCollection = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
text += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
switch local(elementName) {
|
||||
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case "response":
|
||||
if let current { responses.append(current) }
|
||||
current = nil
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms".
|
||||
/// The server may live under a sub-path, so the files root is located by
|
||||
/// its marker instead of assumed to start at the beginning.
|
||||
static func relativePath(fromHref href: String) -> String? {
|
||||
let decoded = href.removingPercentEncoding ?? href
|
||||
let marker = "/remote.php/dav/files/"
|
||||
guard let range = decoded.range(of: marker) else { return nil }
|
||||
var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
parts.removeFirst() // the username
|
||||
return "/" + parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
/// Child folders of [currentPath], sorted by name. The requested folder
|
||||
/// itself (which a Depth-1 PROPFIND also returns) and - unless
|
||||
/// [includeHidden] - dot-folders are left out.
|
||||
static func folders(
|
||||
from xml: Data, excluding currentPath: String, includeHidden: Bool = false
|
||||
) -> [DavFolder] {
|
||||
let delegate = Delegate()
|
||||
let parser = XMLParser(data: xml)
|
||||
parser.delegate = delegate
|
||||
parser.parse()
|
||||
|
||||
let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
||||
return delegate.responses
|
||||
.filter(\.isCollection)
|
||||
.compactMap { response -> DavFolder? in
|
||||
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
||||
let name = (path as NSString).lastPathComponent
|
||||
guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil }
|
||||
return DavFolder(name: name, path: path)
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders over WebDAV for the Share Extension's picker, in the
|
||||
/// foreground (the sheet is on screen while it runs).
|
||||
enum DavClient {
|
||||
private static let body = """
|
||||
<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||
"""
|
||||
|
||||
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
||||
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
||||
else { throw TransferError(message: "Invalid server address.") }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PROPFIND"
|
||||
request.setValue("1", forHTTPHeaderField: "Depth")
|
||||
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
request.timeoutInterval = 20
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard status == 207 else {
|
||||
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
||||
}
|
||||
return DavFolderParser.folders(from: data, excluding: path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
/// Starts the Share Extension's uploads on a background `URLSession` that
|
||||
/// outlives the extension. The extension process is torn down as soon as its
|
||||
/// sheet closes; because the session has a `sharedContainerIdentifier` and
|
||||
/// the same identifier is recreated by the app at launch
|
||||
/// (`TransferManager.reconnect`), the *app* is what gets relaunched to
|
||||
/// receive the results, post the summary notification and clean up.
|
||||
/// Compiled into both targets.
|
||||
enum ShareUpload {
|
||||
static let sessionIdentifier = "dev.ayushya.noo.transfers.share"
|
||||
|
||||
static func configuration() -> URLSessionConfiguration {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
||||
config.sharedContainerIdentifier = AppGroup.identifier
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.isDiscretionary = false
|
||||
config.waitsForConnectivity = true
|
||||
return config
|
||||
}
|
||||
|
||||
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
|
||||
/// [enqueue] and the app's own uploads so both build requests identically.
|
||||
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
|
||||
guard
|
||||
let url = WebDAV.fileURL(
|
||||
serverUrl: account.serverUrl,
|
||||
username: account.username,
|
||||
remotePath: WebDAV.join(remoteFolder, item.name)
|
||||
)
|
||||
else { return nil }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
|
||||
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/// Queues every item (already copied into the App Group by the extension)
|
||||
/// and returns once the system has accepted them.
|
||||
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||
guard !items.isEmpty else { return }
|
||||
let batch = UUID().uuidString
|
||||
// No delegate here: the extension is gone before the results arrive.
|
||||
let session = URLSession(configuration: configuration())
|
||||
|
||||
var tasks: [URLSessionUploadTask] = []
|
||||
for item in items {
|
||||
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
|
||||
throw TransferError(message: "Invalid server address.")
|
||||
}
|
||||
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
|
||||
task.taskDescription =
|
||||
(try? JSONEncoder().encode(
|
||||
TransferTaskInfo(
|
||||
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
|
||||
)).flatMap { String(data: $0, encoding: .utf8) }
|
||||
tasks.append(task)
|
||||
}
|
||||
TransferBatchStore.save(
|
||||
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||
id: batch)
|
||||
tasks.forEach { $0.resume() }
|
||||
session.finishTasksAndInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
struct TransferError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// The signed-in account, as much of it as the Share Extension needs to list
|
||||
/// folders and upload: the app writes it when an account becomes active and
|
||||
/// clears it on sign-out. Compiled into both targets.
|
||||
struct SharedAccount: Codable, Equatable {
|
||||
let serverUrl: String
|
||||
let username: String
|
||||
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
||||
let authHeader: String
|
||||
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// Keeps the [SharedAccount] in a Keychain access group both the app and the
|
||||
/// extension are entitled to (`keychain-access-groups`). The group's full id
|
||||
/// carries the signing team's prefix, so it's injected into each target's
|
||||
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
||||
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
||||
/// then always agree on it, with or without a team.
|
||||
enum SharedAccountStore {
|
||||
private static let service = "dev.ayushya.noo.shared-account"
|
||||
private static let account = "active"
|
||||
|
||||
static var accessGroup: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||
}
|
||||
|
||||
private static func baseQuery() -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
if let group = accessGroup, !group.isEmpty {
|
||||
query[kSecAttrAccessGroup as String] = group
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
static func save(_ value: SharedAccount) throws {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
clear()
|
||||
var query = baseQuery()
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> SharedAccount? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
return try? JSONDecoder().decode(SharedAccount.self, from: data)
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
SecItemDelete(baseQuery() as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
|
||||
/// (no networking), so they're unit-tested in `RunnerTests`.
|
||||
enum WebDAV {
|
||||
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
|
||||
/// character can't introduce an extra path segment.
|
||||
private static let segmentAllowed: CharacterSet = {
|
||||
var set = CharacterSet.urlPathAllowed
|
||||
set.remove(charactersIn: "/")
|
||||
return set
|
||||
}()
|
||||
|
||||
static func encodeSegment(_ segment: String) -> String {
|
||||
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
|
||||
}
|
||||
|
||||
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
|
||||
/// relative to the user's root, with or without a leading slash. A server
|
||||
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
|
||||
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
|
||||
var base = serverUrl
|
||||
while base.hasSuffix("/") { base.removeLast() }
|
||||
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
|
||||
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
|
||||
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
|
||||
return URL(string: url)
|
||||
}
|
||||
|
||||
/// `folder` + `name` with exactly one slash between them.
|
||||
static func join(_ folder: String, _ name: String) -> String {
|
||||
folder.hasSuffix("/") ? folder + name : folder + "/" + name
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalFiles {
|
||||
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
|
||||
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
|
||||
/// never overwrites the first.
|
||||
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
|
||||
let candidate = directory.appendingPathComponent(name)
|
||||
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
|
||||
let ext = (name as NSString).pathExtension
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
var index = 1
|
||||
while true {
|
||||
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
|
||||
let url = directory.appendingPathComponent(numbered)
|
||||
if !fileManager.fileExists(atPath: url.path) { return url }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user