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>
121 lines
4.6 KiB
Swift
121 lines
4.6 KiB
Swift
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)
|
|
}
|
|
}
|