Files
noo/ios/Shared/SharedAccount.swift
T
Ayushya AmitabhandClaude Sonnet 5.5 22a6602f08 Security locks work independently; other-account avatars flush right in the avatar menu
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>
2026-10-06 20:07:28 -04:00

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)
}
}