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:
Ayushya Amitabh
2026-10-06 19:59:29 -04:00
co-authored by Claude Sonnet 5.5
parent 0cda4a1241
commit a37524e959
14 changed files with 581 additions and 87 deletions
+4
View File
@@ -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;
};
+10 -11
View File
@@ -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()
+57 -9
View File
@@ -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() {
+19
View File
@@ -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)
}
}
}
}
+2
View File
@@ -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>
+119 -9
View File
@@ -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)
}
+7 -10
View File
@@ -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
+31 -7
View File
@@ -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)
}
}
+43 -11
View File
@@ -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() {