Share Extension: account picker first, hidden folders follow the app's filter + unlock
The app now publishes every uploadable account, the active one, the three lock settings and each account's own hidden-files filter (ShareAccountSync). The sheet lists accounts first when there are several, asks for unlock to use a non-active account (login lock + account-switching lock), and shows hidden folders per the account's app setting behind the hidden-files unlock. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0cda4a1241
commit
a37524e959
@@ -26,6 +26,7 @@
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
||||
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -107,6 +108,7 @@
|
||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -158,6 +160,7 @@
|
||||
49213989F0FA1B83B0B8A523 /* Info.plist */,
|
||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
|
||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
|
||||
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
|
||||
);
|
||||
name = ShareExtension;
|
||||
path = ShareExtension;
|
||||
@@ -438,6 +441,7 @@
|
||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
||||
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
||||
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -80,22 +80,21 @@ enum NativeServices {
|
||||
|
||||
// MARK: - Account for the Share Extension
|
||||
|
||||
/// `share_account`: Dart tells native which account is active so the Share
|
||||
/// Extension - a separate process with no Flutter engine - can list folders
|
||||
/// and upload as it. Stored in the shared Keychain group.
|
||||
/// `share_account`: Dart hands native every account that can upload, which
|
||||
/// one is active, and the app-lock settings, so the Share Extension - a
|
||||
/// separate process with no Flutter engine - can list folders and upload as
|
||||
/// them. Stored in the shared Keychain group.
|
||||
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
|
||||
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
|
||||
.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "setAccount":
|
||||
case "setAccounts":
|
||||
handle(call, result) { args in
|
||||
try SharedAccountStore.save(
|
||||
SharedAccount(
|
||||
serverUrl: try string(args, "serverUrl"),
|
||||
username: try string(args, "username"),
|
||||
authHeader: try string(args, "authHeader"),
|
||||
displayName: (args["displayName"] as? String) ?? ""
|
||||
))
|
||||
guard let json = args["accounts"] as? String,
|
||||
let data = json.data(using: .utf8),
|
||||
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
|
||||
else { throw TransferError(message: "Bad accounts payload.") }
|
||||
try SharedAccountStore.save(accounts)
|
||||
}
|
||||
case "clearAccount":
|
||||
SharedAccountStore.clear()
|
||||
|
||||
@@ -124,8 +124,24 @@ class RunnerTests: XCTestCase {
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
||||
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name),
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
|
||||
[".git", "Tax Forms", "zeta"])
|
||||
XCTAssertEqual(
|
||||
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
|
||||
[".git"])
|
||||
}
|
||||
|
||||
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
|
||||
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
|
||||
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
|
||||
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
|
||||
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
|
||||
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
|
||||
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
|
||||
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
|
||||
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
|
||||
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
|
||||
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
|
||||
}
|
||||
|
||||
func testFoldersAtRootAndUnderServerSubPath() {
|
||||
@@ -150,19 +166,26 @@ class RunnerTests: XCTestCase {
|
||||
|
||||
// MARK: - Shared account (Keychain group shared with the extension)
|
||||
|
||||
func testSharedAccountRoundTripsThroughTheKeychain() throws {
|
||||
let account = SharedAccount(
|
||||
serverUrl: "https://cloud.example.com", username: "alice",
|
||||
authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com")
|
||||
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
|
||||
SharedAccount(
|
||||
id: id, serverUrl: "https://\(id).example.com", username: id,
|
||||
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
|
||||
}
|
||||
|
||||
func testSharedAccountsRoundTripThroughTheKeychain() throws {
|
||||
let accounts = SharedAccounts(
|
||||
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
|
||||
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
|
||||
addTeardownBlock { SharedAccountStore.clear() }
|
||||
|
||||
SharedAccountStore.clear()
|
||||
XCTAssertNil(SharedAccountStore.load())
|
||||
try SharedAccountStore.save(account)
|
||||
XCTAssertEqual(SharedAccountStore.load(), account)
|
||||
try SharedAccountStore.save(accounts)
|
||||
XCTAssertEqual(SharedAccountStore.load(), accounts)
|
||||
|
||||
let replacement = SharedAccount(
|
||||
serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob")
|
||||
let replacement = SharedAccounts(
|
||||
accounts: [account("carol")], activeId: "carol",
|
||||
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||
try SharedAccountStore.save(replacement)
|
||||
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
||||
|
||||
@@ -170,6 +193,31 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertNil(SharedAccountStore.load())
|
||||
}
|
||||
|
||||
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
|
||||
var shared = SharedAccounts(
|
||||
accounts: [account("alice"), account("bob")], activeId: "bob",
|
||||
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||
XCTAssertEqual(shared.active?.id, "bob")
|
||||
shared.activeId = "gone"
|
||||
XCTAssertEqual(shared.active?.id, "alice")
|
||||
shared.activeId = nil
|
||||
XCTAssertEqual(shared.active?.id, "alice")
|
||||
}
|
||||
|
||||
func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() {
|
||||
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
||||
let shared = SharedAccounts(
|
||||
accounts: [account("a")], activeId: "a",
|
||||
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
||||
}
|
||||
XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false))
|
||||
XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true))
|
||||
XCTAssertTrue(
|
||||
rules(master: false, switching: true, hidden: true) == (false, false),
|
||||
"the sub-toggles mean nothing while the login lock is off, as in the app")
|
||||
}
|
||||
|
||||
// MARK: - TransferBatchStore
|
||||
|
||||
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import LocalAuthentication
|
||||
|
||||
/// Face ID / Touch ID with the device passcode as the fallback - the same
|
||||
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
|
||||
/// the share sheet asks for exactly what the app would.
|
||||
enum DeviceAuth {
|
||||
/// True only if the user actually authenticated; a cancel, a failure, or a
|
||||
/// device with nothing to authenticate with all count as "not unlocked".
|
||||
static func authenticate(reason: String) async -> Bool {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
|
||||
return await withCheckedContinuation { continuation in
|
||||
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>NSExtension</key>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
/// State behind the share sheet's folder picker.
|
||||
/// State behind the share sheet's account and folder pickers.
|
||||
@MainActor
|
||||
final class ShareModel: ObservableObject {
|
||||
enum Stage: Equatable {
|
||||
case preparing
|
||||
case chooseAccount
|
||||
case picking
|
||||
case uploading
|
||||
case noAccount
|
||||
@@ -17,18 +18,30 @@ final class ShareModel: ObservableObject {
|
||||
@Published var folders: [DavFolder] = []
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var folderError: String?
|
||||
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||
/// hidden because the unlock was cancelled.
|
||||
@Published var notice: String?
|
||||
@Published private(set) var selected: SharedAccount?
|
||||
|
||||
let account: SharedAccount?
|
||||
let shared: SharedAccounts?
|
||||
|
||||
/// One successful unlock covers every gate for the rest of this sheet.
|
||||
private var unlocked = false
|
||||
private var hidden: HiddenFilter = .hide
|
||||
|
||||
/// Set by the view controller: what each button actually does.
|
||||
var onUpload: () -> Void = {}
|
||||
var onSaveForLater: () -> Void = {}
|
||||
var onCancel: () -> Void = {}
|
||||
|
||||
init(account: SharedAccount?) {
|
||||
self.account = account
|
||||
init(shared: SharedAccounts?) {
|
||||
self.shared = shared
|
||||
}
|
||||
|
||||
var accounts: [SharedAccount] { shared?.accounts ?? [] }
|
||||
var canChangeAccount: Bool { accounts.count > 1 }
|
||||
var activeAccountId: String? { shared?.active?.id }
|
||||
|
||||
var currentFolderName: String {
|
||||
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||
}
|
||||
@@ -37,14 +50,66 @@ final class ShareModel: ObservableObject {
|
||||
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||
}
|
||||
|
||||
/// With several accounts the list comes first; with one it goes straight
|
||||
/// to its folders.
|
||||
func start() async {
|
||||
guard let shared, let first = shared.accounts.first else {
|
||||
stage = .noAccount
|
||||
return
|
||||
}
|
||||
if shared.accounts.count > 1 {
|
||||
stage = .chooseAccount
|
||||
} else {
|
||||
await select(first)
|
||||
}
|
||||
}
|
||||
|
||||
func showAccounts() {
|
||||
notice = nil
|
||||
stage = .chooseAccount
|
||||
}
|
||||
|
||||
/// Uploading to an account other than the one the app is on is "switching"
|
||||
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||
/// Hidden folders follow the account's own app setting, behind the app's
|
||||
/// "lock hidden files" unlock.
|
||||
func select(_ account: SharedAccount) async {
|
||||
guard let shared else { return }
|
||||
notice = nil
|
||||
if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount {
|
||||
guard await unlock("Unlock to upload to \(account.displayName)") else {
|
||||
notice = "Unlock to upload to a different account."
|
||||
stage = .chooseAccount
|
||||
return
|
||||
}
|
||||
}
|
||||
selected = account
|
||||
hidden = HiddenFilter(raw: account.hiddenFilter)
|
||||
if hidden != .hide, shared.needsUnlockForHidden {
|
||||
if await !unlock("Unlock to show hidden folders") {
|
||||
hidden = .hide
|
||||
notice = "Hidden folders are locked, so they're not shown."
|
||||
}
|
||||
}
|
||||
stage = .picking
|
||||
await open("/")
|
||||
}
|
||||
|
||||
private func unlock(_ reason: String) async -> Bool {
|
||||
if unlocked { return true }
|
||||
let ok = await DeviceAuth.authenticate(reason: reason)
|
||||
if ok { unlocked = true }
|
||||
return ok
|
||||
}
|
||||
|
||||
func open(_ path: String) async {
|
||||
guard let account else { return }
|
||||
guard let account = selected else { return }
|
||||
self.path = path
|
||||
isLoadingFolders = true
|
||||
folderError = nil
|
||||
defer { isLoadingFolders = false }
|
||||
do {
|
||||
folders = try await DavClient.listFolders(account: account, path: path)
|
||||
folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden)
|
||||
} catch {
|
||||
folders = []
|
||||
folderError = error.localizedDescription
|
||||
@@ -107,19 +172,48 @@ struct SharePickerView: View {
|
||||
centered {
|
||||
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||
}
|
||||
case .chooseAccount:
|
||||
accountList
|
||||
case .picking:
|
||||
folderList
|
||||
}
|
||||
}
|
||||
|
||||
private var accountList: some View {
|
||||
List {
|
||||
Section(
|
||||
header: Text("Upload \(model.fileCountText) to which account?"),
|
||||
footer: model.notice.map { Text($0) }
|
||||
) {
|
||||
ForEach(model.accounts) { account in
|
||||
Button {
|
||||
Task { await model.select(account) }
|
||||
} label: {
|
||||
HStack {
|
||||
Label(account.displayName, systemImage: "person.crop.circle")
|
||||
Spacer()
|
||||
if account.id == model.activeAccountId {
|
||||
Text("Active").font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private var folderList: some View {
|
||||
List {
|
||||
Section(header: Text(model.account?.displayName ?? "")) {
|
||||
Section(
|
||||
header: accountHeader,
|
||||
footer: model.notice.map { Text($0) }
|
||||
) {
|
||||
if model.path != "/" {
|
||||
Button {
|
||||
Task { await model.openParent() }
|
||||
} label: {
|
||||
Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up")
|
||||
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
|
||||
}
|
||||
}
|
||||
if model.isLoadingFolders {
|
||||
@@ -142,6 +236,22 @@ struct SharePickerView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private var accountHeader: some View {
|
||||
HStack {
|
||||
Text(model.selected?.displayName ?? "")
|
||||
Spacer()
|
||||
if model.canChangeAccount {
|
||||
Button("Change account") { model.showAccounts() }
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var parentName: String {
|
||||
let parent = (model.path as NSString).deletingLastPathComponent
|
||||
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 8) {
|
||||
if model.stage == .picking {
|
||||
@@ -154,7 +264,7 @@ struct SharePickerView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.isLoadingFolders)
|
||||
}
|
||||
if model.stage == .picking || model.stage == .noAccount {
|
||||
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
|
||||
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import UserNotifications
|
||||
/// shows a folder picker, then starts a background upload and closes:
|
||||
///
|
||||
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||
/// 2. Reads the signed-in account the app left in the shared Keychain
|
||||
/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`).
|
||||
/// 2. Reads the accounts the app left in the shared Keychain
|
||||
/// (`SharedAccountStore`) - asking which one first if there are several -
|
||||
/// and lists folders over WebDAV (`DavClient`), honouring the app's
|
||||
/// hidden-files filter and its unlock settings (`ShareModel`).
|
||||
/// 3. "Upload" queues the files on a background session that outlives this
|
||||
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||
///
|
||||
@@ -22,7 +24,7 @@ final class ShareViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
model = ShareModel(account: SharedAccountStore.load())
|
||||
model = ShareModel(shared: SharedAccountStore.load())
|
||||
model.onUpload = { [weak self] in self?.upload() }
|
||||
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||
model.onCancel = { [weak self] in self?.cancel() }
|
||||
@@ -68,12 +70,7 @@ final class ShareViewController: UIViewController {
|
||||
model.stage = .failed("Noo can only receive files and photos.")
|
||||
return
|
||||
}
|
||||
guard model.account != nil else {
|
||||
model.stage = .noAccount
|
||||
return
|
||||
}
|
||||
model.stage = .picking
|
||||
await model.open("/")
|
||||
await model.start()
|
||||
}
|
||||
|
||||
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||
@@ -102,7 +99,7 @@ final class ShareViewController: UIViewController {
|
||||
// MARK: - Actions
|
||||
|
||||
private func upload() {
|
||||
guard let account = model.account else { return }
|
||||
guard let account = model.selected else { return }
|
||||
do {
|
||||
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||
model.stage = .uploading
|
||||
|
||||
@@ -7,6 +7,30 @@ struct DavFolder: Equatable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
|
||||
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
|
||||
/// list everything (`include`). A folder counts as hidden when it - or any
|
||||
/// folder above it - starts with a dot, exactly as in the app.
|
||||
enum HiddenFilter: String {
|
||||
case hide, only, include
|
||||
|
||||
init(raw: String) {
|
||||
self = HiddenFilter(rawValue: raw) ?? .hide
|
||||
}
|
||||
|
||||
static func isHidden(path: String) -> Bool {
|
||||
path.split(separator: "/").contains { $0.hasPrefix(".") }
|
||||
}
|
||||
|
||||
func shows(path: String) -> Bool {
|
||||
switch self {
|
||||
case .hide: return !Self.isHidden(path: path)
|
||||
case .only: return Self.isHidden(path: path)
|
||||
case .include: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||
/// networking), so it's unit-tested. Compiled into both targets.
|
||||
enum DavFolderParser {
|
||||
@@ -67,11 +91,11 @@ enum DavFolderParser {
|
||||
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.
|
||||
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
|
||||
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
|
||||
/// always left out.
|
||||
static func folders(
|
||||
from xml: Data, excluding currentPath: String, includeHidden: Bool = false
|
||||
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
|
||||
) -> [DavFolder] {
|
||||
let delegate = Delegate()
|
||||
let parser = XMLParser(data: xml)
|
||||
@@ -84,7 +108,7 @@ enum DavFolderParser {
|
||||
.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 }
|
||||
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
|
||||
return DavFolder(name: name, path: path)
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
@@ -99,7 +123,7 @@ enum DavClient {
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||
"""
|
||||
|
||||
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
||||
static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) 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)
|
||||
@@ -115,6 +139,6 @@ enum DavClient {
|
||||
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)
|
||||
return DavFolderParser.folders(from: data, excluding: path, hidden: hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
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 {
|
||||
/// One signed-in account, as much of it as the Share Extension needs to list
|
||||
/// folders and upload. Compiled into both targets.
|
||||
struct SharedAccount: Codable, Equatable, Identifiable {
|
||||
let id: String
|
||||
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
|
||||
/// The app's Files "hidden files" filter for this account - `hide` (the
|
||||
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
|
||||
/// follows it instead of having a setting of its own.
|
||||
let hiddenFilter: String
|
||||
}
|
||||
|
||||
/// Keeps the [SharedAccount] in a Keychain access group both the app and the
|
||||
/// Everything the app publishes for the extension: every account that can
|
||||
/// upload, which one is active in the app, and the app-lock settings the
|
||||
/// extension has to honour. The app rewrites it whenever any of that changes.
|
||||
struct SharedAccounts: Codable, Equatable {
|
||||
var accounts: [SharedAccount]
|
||||
var activeId: String?
|
||||
/// Settings -> Security: the master "login lock" and its two sub-toggles.
|
||||
/// The sub-toggles only count while the master one is on (as in the app).
|
||||
var loginLockEnabled: Bool
|
||||
var lockAccountSwitching: Bool
|
||||
var lockHiddenFiles: Bool
|
||||
|
||||
/// The account the app is currently using, else the first one.
|
||||
var active: SharedAccount? {
|
||||
accounts.first { $0.id == activeId } ?? accounts.first
|
||||
}
|
||||
|
||||
/// Uploading to an account other than the active one is "switching" in
|
||||
/// the app's terms, so it needs the same unlock.
|
||||
var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching }
|
||||
|
||||
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||
/// turn hidden files on.
|
||||
var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles }
|
||||
}
|
||||
|
||||
/// Keeps the [SharedAccounts] 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"
|
||||
private static let service = "dev.ayushya.noo.shared-accounts"
|
||||
private static let account = "all"
|
||||
|
||||
static var accessGroup: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||
@@ -39,7 +69,7 @@ enum SharedAccountStore {
|
||||
return query
|
||||
}
|
||||
|
||||
static func save(_ value: SharedAccount) throws {
|
||||
static func save(_ value: SharedAccounts) throws {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
clear()
|
||||
var query = baseQuery()
|
||||
@@ -51,15 +81,17 @@ enum SharedAccountStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func load() -> SharedAccount? {
|
||||
static func load() -> SharedAccounts? {
|
||||
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
|
||||
let data = result as? Data,
|
||||
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
||||
!decoded.accounts.isEmpty
|
||||
else { return nil }
|
||||
return try? JSONDecoder().decode(SharedAccount.self, from: data)
|
||||
return decoded
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
|
||||
Reference in New Issue
Block a user