Login lock, lock account switching and lock hidden files no longer depend on each other: passGate prompts on its own flag, the two sub-locks can be set with login lock off, and disabling login lock leaves them alone. Turning any lock on or off asks for device auth (on also checks the device can). The iOS Share Extension's unlock rules follow. The avatar dropdown's additional accounts lose the 44px spacer so their avatars sit at the right edge. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
4.1 KiB
Swift
108 lines
4.1 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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 three independent locks. `loginLockEnabled`
|
|
/// (unlock to open the app) is carried for completeness; the extension's own
|
|
/// gates are the other two.
|
|
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 { lockAccountSwitching }
|
|
|
|
/// Showing hidden folders needs the same unlock the app asks for when you
|
|
/// turn hidden files on.
|
|
var needsUnlockForHidden: Bool { 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-accounts"
|
|
private static let account = "all"
|
|
|
|
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: SharedAccounts) 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() -> 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 decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
|
!decoded.accounts.isEmpty
|
|
else { return nil }
|
|
return decoded
|
|
}
|
|
|
|
static func clear() {
|
|
SecItemDelete(baseQuery() as CFDictionary)
|
|
// The first version kept a single account under its own service name;
|
|
// don't leave that credential behind.
|
|
var legacy = baseQuery()
|
|
legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account"
|
|
legacy[kSecAttrAccount as String] = "active"
|
|
SecItemDelete(legacy as CFDictionary)
|
|
}
|
|
}
|