mirror of
https://github.com/yattee/yattee.git
synced 2026-10-30 16:37:17 +00:00
Remove keychain provenance and identity scope simulator debug tools
Both were DEBUG-only Developer settings entries used while building the accounts revamp and are no longer needed. The simulator's account-sync suspension barrier in AccountSyncCoordinator existed only to keep invented scopes away from the real zone, so it goes with it.
This commit is contained in:
@@ -95,19 +95,6 @@ final class AccountSyncCoordinator {
|
|||||||
private var lastResolvedNamespace: String?
|
private var lastResolvedNamespace: String?
|
||||||
|
|
||||||
|
|
||||||
/// Set once the DEBUG identity-scope simulator has invented a scope, and
|
|
||||||
/// never cleared: from that moment this process is describing an Apple
|
|
||||||
/// account it is not signed in to.
|
|
||||||
///
|
|
||||||
/// Only the *namespaces* are invented — the database, the zone and the
|
|
||||||
/// records in it stay real. Without this, activating under `debug-identity-A`
|
|
||||||
/// would bootstrap that namespace out of the genuine private zone and then
|
|
||||||
/// upload the results back into it stamped with a name no identity will ever
|
|
||||||
/// answer to, which is real data lost inside a real zone. Compiled in every
|
|
||||||
/// configuration so the guards below read the same everywhere; only DEBUG
|
|
||||||
/// has any way to set it.
|
|
||||||
private var debugSimulationActive = false
|
|
||||||
|
|
||||||
private var isActivating = false
|
private var isActivating = false
|
||||||
private var activationRequested = false
|
private var activationRequested = false
|
||||||
|
|
||||||
@@ -282,31 +269,6 @@ final class AccountSyncCoordinator {
|
|||||||
database = nil
|
database = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Suspends account sync for the rest of this process, for the identity
|
|
||||||
/// scope simulator in Developer settings.
|
|
||||||
///
|
|
||||||
/// Called *before* the first invented scope is announced. The simulator's
|
|
||||||
/// whole point is to drive the local→identity import without an Apple
|
|
||||||
/// account to sign into, and the namespaces it invents are only safe as long
|
|
||||||
/// as nothing carries them to the server: the private database underneath is
|
|
||||||
/// the real one, so a bootstrap under a fake namespace reads genuine records
|
|
||||||
/// and the upload that follows writes them back stamped with a name the real
|
|
||||||
/// identity will never recognise.
|
|
||||||
///
|
|
||||||
/// Never lifted. The simulated scope itself lasts until the app is
|
|
||||||
/// relaunched, and a coordinator that resumed while the scope was still
|
|
||||||
/// invented would be exactly the thing this prevents.
|
|
||||||
func enterDebugScopeSimulation() {
|
|
||||||
guard !debugSimulationActive else { return }
|
|
||||||
debugSimulationActive = true
|
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit(
|
|
||||||
"Account sync is suspended for this launch: the identity scope is being simulated"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Fetching
|
// MARK: - Fetching
|
||||||
|
|
||||||
/// Notes which identity the fetch now starting belongs to.
|
/// Notes which identity the fetch now starting belongs to.
|
||||||
@@ -331,23 +293,6 @@ final class AccountSyncCoordinator {
|
|||||||
|
|
||||||
guard !accountRecords.isEmpty || !accountDeletions.isEmpty else { return }
|
guard !accountRecords.isEmpty || !accountDeletions.isEmpty else { return }
|
||||||
|
|
||||||
// Refused rather than staged, and treated exactly like records that
|
|
||||||
// arrived before an identity could be attributed to them. Staging is
|
|
||||||
// namespaced, so these genuine records would be filed under an invented
|
|
||||||
// name — where applying them is the pollution this is preventing, and
|
|
||||||
// not applying them is a durable copy of real data nothing will read
|
|
||||||
// again. The recovery flag is what makes them recoverable: the engine's
|
|
||||||
// token has moved past them, and the real identity re-bootstraps its
|
|
||||||
// zone from a nil token on the next launch, which is when the scope is
|
|
||||||
// real again.
|
|
||||||
guard !debugSimulationActive else {
|
|
||||||
store.hasPendingIdentityRecovery = true
|
|
||||||
LoggingService.shared.logCloudKit(
|
|
||||||
"Dropping \(accountRecords.count + accountDeletions.count) account records fetched under a simulated identity"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The identity the *fetch* began under, not whatever is current now.
|
// The identity the *fetch* began under, not whatever is current now.
|
||||||
let owner = fetchIdentity ?? (namespace, identityGeneration)
|
let owner = fetchIdentity ?? (namespace, identityGeneration)
|
||||||
|
|
||||||
@@ -417,15 +362,6 @@ final class AccountSyncCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performActivation() async {
|
private func performActivation() async {
|
||||||
// Before anything is read or written, and on the same terms as the
|
|
||||||
// category being off: staged records are kept, nothing is applied, and
|
|
||||||
// nothing is enqueued. A simulated scope names an Apple account this
|
|
||||||
// device is not signed in to, while the database it would activate
|
|
||||||
// against is that device's real one.
|
|
||||||
guard !debugSimulationActive else {
|
|
||||||
LoggingService.shared.logCloudKit("Account sync is suspended: the identity scope is simulated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let namespace else { return }
|
guard let namespace else { return }
|
||||||
guard isCategoryEnabled() else {
|
guard isCategoryEnabled() else {
|
||||||
LoggingService.shared.logCloudKit("Account sync category is off — incoming records stay deferred")
|
LoggingService.shared.logCloudKit("Account sync category is off — incoming records stay deferred")
|
||||||
@@ -628,10 +564,6 @@ final class AccountSyncCoordinator {
|
|||||||
/// Runs outside the engine's own token: the engine's state and every other
|
/// Runs outside the engine's own token: the engine's state and every other
|
||||||
/// category's pending changes are untouched.
|
/// category's pending changes are untouched.
|
||||||
private func runBootstrapIfNeeded(in namespace: String) async {
|
private func runBootstrapIfNeeded(in namespace: String) async {
|
||||||
// Unreachable through `performActivation`, which already refuses. Kept
|
|
||||||
// because this is the one place that reads the real zone from a nil
|
|
||||||
// token, and it must never do that on behalf of an invented namespace.
|
|
||||||
guard !debugSimulationActive else { return }
|
|
||||||
guard !store.isBootstrapCompleted(in: namespace) else { return }
|
guard !store.isBootstrapCompleted(in: namespace) else { return }
|
||||||
guard let database else { return }
|
guard let database else { return }
|
||||||
|
|
||||||
@@ -721,8 +653,6 @@ final class AccountSyncCoordinator {
|
|||||||
/// Safe to call repeatedly: the engine collapses duplicate pending changes,
|
/// Safe to call repeatedly: the engine collapses duplicate pending changes,
|
||||||
/// and each entry is cleared only by a server confirmation.
|
/// and each entry is cleared only by a server confirmation.
|
||||||
func enqueueDirtyRecords() {
|
func enqueueDirtyRecords() {
|
||||||
// Nothing an invented namespace owes is owed to anybody's real zone.
|
|
||||||
guard !debugSimulationActive else { return }
|
|
||||||
guard let enqueueChanges, let namespace else { return }
|
guard let enqueueChanges, let namespace else { return }
|
||||||
guard isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return }
|
guard isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return }
|
||||||
guard isManagerLoaded(for: namespace) else { return }
|
guard isManagerLoaded(for: namespace) else { return }
|
||||||
@@ -764,10 +694,6 @@ final class AccountSyncCoordinator {
|
|||||||
/// as done.
|
/// as done.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func uploadEverything() -> Bool {
|
func uploadEverything() -> Bool {
|
||||||
// A simulated identity has no zone to fill, and marking its accounts
|
|
||||||
// dirty would only leave an outbox nothing can ever settle.
|
|
||||||
guard !debugSimulationActive else { return false }
|
|
||||||
|
|
||||||
// Marking everything dirty commits the *in-memory* list to this
|
// Marking everything dirty commits the *in-memory* list to this
|
||||||
// identity's zone, so it must not run against a snapshot loaded under
|
// identity's zone, so it must not run against a snapshot loaded under
|
||||||
// another one.
|
// another one.
|
||||||
@@ -792,9 +718,7 @@ final class AccountSyncCoordinator {
|
|||||||
/// must come back to it otherwise.
|
/// must come back to it otherwise.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func discardWhatTheZoneHeld() -> Bool {
|
func discardWhatTheZoneHeld() -> Bool {
|
||||||
// A simulated identity's zone is nobody's, and the outbox it would
|
guard let namespace else { return false }
|
||||||
// empty belongs to the identity actually signed in.
|
|
||||||
guard !debugSimulationActive, let namespace else { return false }
|
|
||||||
|
|
||||||
// Fenced before anything is destroyed, the way `uploadEverything()` is:
|
// Fenced before anything is destroyed, the way `uploadEverything()` is:
|
||||||
// during the reload gap the accounts in memory are still the previous
|
// during the reload gap the accounts in memory are still the previous
|
||||||
@@ -850,10 +774,6 @@ final class AccountSyncCoordinator {
|
|||||||
/// upload ordering. Holding is safe because the outbox is durable: the
|
/// upload ordering. Holding is safe because the outbox is durable: the
|
||||||
/// change is registered again as soon as what it waits on is confirmed.
|
/// change is registered again as soon as what it waits on is confirmed.
|
||||||
func recordToSave(named recordName: String) -> CKRecord? {
|
func recordToSave(named recordName: String) -> CKRecord? {
|
||||||
// The last gate before a record reaches the server, and the one that
|
|
||||||
// would stamp it with a simulated identity. Withdrawn instead: the
|
|
||||||
// outbox is durable, and this launch has nowhere legitimate to send it.
|
|
||||||
guard !debugSimulationActive else { return nil }
|
|
||||||
guard let namespace, isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return nil }
|
guard let namespace, isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return nil }
|
||||||
guard isManagerLoaded(for: namespace) else { return nil }
|
guard isManagerLoaded(for: namespace) else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
//
|
|
||||||
// KeychainProvenanceProbe.swift
|
|
||||||
// Yattee
|
|
||||||
//
|
|
||||||
// Debug-only diagnostic that settles one open question in the accounts
|
|
||||||
// revamp: can a Keychain query prove which Apple account a stored credential
|
|
||||||
// came from?
|
|
||||||
//
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import Foundation
|
|
||||||
import Security
|
|
||||||
|
|
||||||
/// Empirical probe for the credential-adoption question in the accounts
|
|
||||||
/// revamp (design decision D3).
|
|
||||||
///
|
|
||||||
/// Yattee 2 stores instance credentials under an *unscoped* key
|
|
||||||
/// (`kSecAttrAccount = instance.id.uuidString`) and reads them back with
|
|
||||||
/// `kSecAttrSynchronizableAny`. The revamp re-keys them per Apple account
|
|
||||||
/// (`"<userRecordID|local>|<accountID>"`) so that a credential retained on
|
|
||||||
/// device across an Apple-account switch can never be served to the new
|
|
||||||
/// account. That leaves one thing to decide: on upgrade, may the app adopt the
|
|
||||||
/// existing unscoped item automatically, or must every user tap an explicit
|
|
||||||
/// "use saved login from this device" action?
|
|
||||||
///
|
|
||||||
/// Adopting automatically is only safe with *provenance of the secret itself*.
|
|
||||||
/// The candidate discriminator is a **strict `kSecAttrSynchronizable == true`**
|
|
||||||
/// query: the hypothesis is that an item actively served from the *current*
|
|
||||||
/// account's iCloud Keychain matches it, while a credential retained after
|
|
||||||
/// signing out of a *previous* account survives only as a local copy and does
|
|
||||||
/// not. Nothing in Apple's documentation states this either way, so it has to
|
|
||||||
/// be measured on a real device across a real account switch.
|
|
||||||
///
|
|
||||||
/// This probe writes to its own Keychain service and therefore can never read,
|
|
||||||
/// modify, or delete a real Yattee credential.
|
|
||||||
///
|
|
||||||
/// Everything else mirrors the production managers exactly — same item class,
|
|
||||||
/// same delete-then-add write, same attribute set — so the measured behaviour
|
|
||||||
/// transfers to `InvidiousCredentialsManager` / `PipedCredentialsManager`.
|
|
||||||
enum KeychainProvenanceProbe {
|
|
||||||
/// Dedicated service name: isolates the probe from `com.yattee.invidious`
|
|
||||||
/// and `com.yattee.piped`.
|
|
||||||
static let service = "com.yattee.keychain-provenance-experiment"
|
|
||||||
|
|
||||||
/// Stands in for the legacy credential: written with
|
|
||||||
/// `kSecAttrSynchronizable = true`, exactly like a real logged-in instance
|
|
||||||
/// while iCloud sync is on.
|
|
||||||
static let synchronizableAccount = "probe-synchronizable"
|
|
||||||
|
|
||||||
/// Control item written with `kSecAttrSynchronizable = false` — never
|
|
||||||
/// eligible for iCloud Keychain, so it isolates "survived the account
|
|
||||||
/// switch" from "was synced".
|
|
||||||
static let localAccount = "probe-local"
|
|
||||||
|
|
||||||
// MARK: - Queries
|
|
||||||
|
|
||||||
/// The three ways the app can ask for an item, and the question each answers.
|
|
||||||
enum SyncMatch: String, CaseIterable, Identifiable {
|
|
||||||
/// The candidate discriminator.
|
|
||||||
case synchronizableTrue
|
|
||||||
/// Catches items whose synchronizable flag was cleared on sign-out.
|
|
||||||
case synchronizableFalse
|
|
||||||
/// What the production managers use today — matches either form.
|
|
||||||
case any
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var queryValue: Any {
|
|
||||||
switch self {
|
|
||||||
case .synchronizableTrue: kCFBooleanTrue as Any
|
|
||||||
case .synchronizableFalse: kCFBooleanFalse as Any
|
|
||||||
case .any: kSecAttrSynchronizableAny as Any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .synchronizableTrue: "kSecAttrSynchronizable == true (candidate discriminator)"
|
|
||||||
case .synchronizableFalse: "kSecAttrSynchronizable == false"
|
|
||||||
case .any: "kSecAttrSynchronizableAny (what Yattee queries today)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single matched Keychain item, flattened for reporting.
|
|
||||||
struct ItemDump: Identifiable, Sendable {
|
|
||||||
let id = UUID()
|
|
||||||
let account: String
|
|
||||||
/// The attribute as stored — not as queried. The interesting column
|
|
||||||
/// after an account switch.
|
|
||||||
let synchronizable: Bool?
|
|
||||||
let accessible: String?
|
|
||||||
let creationDate: Date?
|
|
||||||
let modificationDate: Date?
|
|
||||||
/// Records which identity wrote the item, so a retained item can be
|
|
||||||
/// attributed to the previous Apple account after a switch.
|
|
||||||
let payload: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QueryResult: Identifiable, Sendable {
|
|
||||||
var id: String { match.rawValue }
|
|
||||||
let match: SyncMatch
|
|
||||||
let status: OSStatus
|
|
||||||
let items: [ItemDump]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Write
|
|
||||||
|
|
||||||
/// Writes a probe item, mirroring the production delete-then-add pattern.
|
|
||||||
///
|
|
||||||
/// The delete leg spans both synchronizable forms, exactly like
|
|
||||||
/// `InvidiousCredentialsManager.setSID(_:for:)` — which means calling this
|
|
||||||
/// destroys a retained item from a previous account. Only call it when the
|
|
||||||
/// protocol step says to.
|
|
||||||
@discardableResult
|
|
||||||
static func write(account: String, synchronizable: Bool, payload: String) -> OSStatus {
|
|
||||||
let deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: account,
|
|
||||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
|
||||||
]
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
|
|
||||||
let addQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: account,
|
|
||||||
kSecAttrSynchronizable as String: synchronizable,
|
|
||||||
kSecValueData as String: Data(payload.utf8)
|
|
||||||
]
|
|
||||||
|
|
||||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
||||||
|
|
||||||
LoggingService.shared.info(
|
|
||||||
"Keychain provenance probe written",
|
|
||||||
category: .keychain,
|
|
||||||
details: "account=\(account), synchronizable=\(synchronizable), status=\(describe(status))"
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes every probe item in both synchronizable forms.
|
|
||||||
///
|
|
||||||
/// Deleting a synchronizable item propagates through iCloud Keychain to
|
|
||||||
/// every device signed in to the same Apple account — intended here, since
|
|
||||||
/// this is how the experiment is reset between runs.
|
|
||||||
@discardableResult
|
|
||||||
static func deleteAll() -> OSStatus {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
|
||||||
]
|
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
|
|
||||||
LoggingService.shared.info(
|
|
||||||
"Keychain provenance probes deleted",
|
|
||||||
category: .keychain,
|
|
||||||
details: "status=\(describe(status))"
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Read
|
|
||||||
|
|
||||||
/// Runs one query and returns every matching item with its stored attributes.
|
|
||||||
static func query(_ match: SyncMatch) -> QueryResult {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrSynchronizable as String: match.queryValue,
|
|
||||||
kSecReturnAttributes as String: true,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
let attributes = result as? [[String: Any]] ?? []
|
|
||||||
|
|
||||||
return QueryResult(
|
|
||||||
match: match,
|
|
||||||
status: status,
|
|
||||||
items: attributes.map(dump).sorted { $0.account < $1.account }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All three queries, in reporting order.
|
|
||||||
static func queryAll() -> [QueryResult] {
|
|
||||||
SyncMatch.allCases.map(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func dump(_ attributes: [String: Any]) -> ItemDump {
|
|
||||||
ItemDump(
|
|
||||||
account: attributes[kSecAttrAccount as String] as? String ?? "<none>",
|
|
||||||
synchronizable: (attributes[kSecAttrSynchronizable as String] as? NSNumber)?.boolValue,
|
|
||||||
accessible: attributes[kSecAttrAccessible as String] as? String,
|
|
||||||
creationDate: attributes[kSecAttrCreationDate as String] as? Date,
|
|
||||||
modificationDate: attributes[kSecAttrModificationDate as String] as? Date,
|
|
||||||
payload: (attributes[kSecValueData as String] as? Data)
|
|
||||||
.flatMap { String(data: $0, encoding: .utf8) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Status
|
|
||||||
|
|
||||||
static func describe(_ status: OSStatus) -> String {
|
|
||||||
if status == errSecSuccess { return "success (0)" }
|
|
||||||
let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown"
|
|
||||||
return "\(message) (\(status))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
//
|
|
||||||
// KeychainProvenanceReport.swift
|
|
||||||
// Yattee
|
|
||||||
//
|
|
||||||
// Captures and persists the output of KeychainProvenanceProbe so results
|
|
||||||
// survive the Apple-account sign-out/sign-in steps of the experiment.
|
|
||||||
//
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// One captured run of the experiment.
|
|
||||||
///
|
|
||||||
/// Reports are persisted because the protocol spans an Apple-account sign-out
|
|
||||||
/// and sign-in: the reading taken *before* the switch is half the evidence, and
|
|
||||||
/// it must still be readable after it.
|
|
||||||
struct KeychainProvenanceReport: Codable, Identifiable, Sendable {
|
|
||||||
let id: UUID
|
|
||||||
let capturedAt: Date
|
|
||||||
let label: String
|
|
||||||
let text: String
|
|
||||||
|
|
||||||
init(id: UUID = UUID(), capturedAt: Date = Date(), label: String, text: String) {
|
|
||||||
self.id = id
|
|
||||||
self.capturedAt = capturedAt
|
|
||||||
self.label = label
|
|
||||||
self.text = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UserDefaults-backed store for captured reports.
|
|
||||||
///
|
|
||||||
/// Deliberately not synced anywhere — the whole point is a per-device record.
|
|
||||||
enum KeychainProvenanceReportStore {
|
|
||||||
private static let defaultsKey = "debug.keychainProvenanceReports"
|
|
||||||
|
|
||||||
static func load() -> [KeychainProvenanceReport] {
|
|
||||||
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
|
|
||||||
let reports = try? JSONDecoder().decode([KeychainProvenanceReport].self, from: data)
|
|
||||||
else { return [] }
|
|
||||||
return reports
|
|
||||||
}
|
|
||||||
|
|
||||||
static func append(_ report: KeychainProvenanceReport) -> [KeychainProvenanceReport] {
|
|
||||||
let reports = load() + [report]
|
|
||||||
save(reports)
|
|
||||||
return reports
|
|
||||||
}
|
|
||||||
|
|
||||||
static func clear() {
|
|
||||||
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func save(_ reports: [KeychainProvenanceReport]) {
|
|
||||||
guard let data = try? JSONEncoder().encode(reports) else { return }
|
|
||||||
UserDefaults.standard.set(data, forKey: defaultsKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Report text
|
|
||||||
|
|
||||||
enum KeychainProvenanceReportBuilder {
|
|
||||||
/// Builds the full plain-text report for the current device state.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - label: free-form run label, e.g. "iPhone 15 — signed in as identity B".
|
|
||||||
/// - userRecordID: resolved asynchronously by the caller (network), `nil`
|
|
||||||
/// when unavailable.
|
|
||||||
static func build(
|
|
||||||
label: String,
|
|
||||||
userRecordID: String?,
|
|
||||||
accountAvailability: CloudKitIdentity.AccountAvailability
|
|
||||||
) -> String {
|
|
||||||
let results = KeychainProvenanceProbe.queryAll()
|
|
||||||
|
|
||||||
var lines: [String] = []
|
|
||||||
lines.append("=== Yattee Keychain provenance experiment ===")
|
|
||||||
lines.append("Label: \(label.isEmpty ? "<unlabelled>" : label)")
|
|
||||||
lines.append("Captured: \(iso(Date()))")
|
|
||||||
lines.append("Platform: \(platformDescription)")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("--- Identity ---")
|
|
||||||
lines.append("CloudKit entitlement: \(CloudKitAvailability.isAvailable ? "available" : "missing (sideload)")")
|
|
||||||
// A nil token does NOT mean signed out — Foundation also returns nil
|
|
||||||
// when iCloud Drive is off, so the report must not assert either way.
|
|
||||||
// CloudKit's account status is what disambiguates.
|
|
||||||
lines.append("ubiquityIdentityToken: \(CloudKitIdentity.ubiquityFingerprint.map { "present, fp=\($0)" } ?? "unavailable (signed out, or iCloud Drive off)")")
|
|
||||||
lines.append("CloudKit account: \(describe(accountAvailability))")
|
|
||||||
lines.append("CloudKit user record: \(userRecordID ?? "unresolved")")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("--- Keychain queries (service \(KeychainProvenanceProbe.service)) ---")
|
|
||||||
for result in results {
|
|
||||||
lines.append("")
|
|
||||||
lines.append("[\(result.match.label)]")
|
|
||||||
lines.append(" status: \(KeychainProvenanceProbe.describe(result.status)), \(result.items.count) item(s)")
|
|
||||||
for item in result.items {
|
|
||||||
lines.append(" • account=\(item.account)")
|
|
||||||
lines.append(" stored synchronizable: \(item.synchronizable.map(String.init(describing:)) ?? "absent")")
|
|
||||||
lines.append(" accessible: \(item.accessible ?? "absent")")
|
|
||||||
lines.append(" created: \(item.creationDate.map(iso) ?? "absent")")
|
|
||||||
lines.append(" modified: \(item.modificationDate.map(iso) ?? "absent")")
|
|
||||||
lines.append(" payload: \(item.payload ?? "<not utf8>")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("--- Discriminator check ---")
|
|
||||||
lines.append(contentsOf: discriminatorLines(results))
|
|
||||||
|
|
||||||
return lines.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compares what the candidate discriminator matched against what Yattee's
|
|
||||||
/// current `SynchronizableAny` query matches — the difference is exactly
|
|
||||||
/// what auto-adoption would refuse to adopt.
|
|
||||||
private static func discriminatorLines(
|
|
||||||
_ results: [KeychainProvenanceProbe.QueryResult]
|
|
||||||
) -> [String] {
|
|
||||||
let strict = Set(results.first { $0.match == .synchronizableTrue }?.items.map(\.account) ?? [])
|
|
||||||
let any = Set(results.first { $0.match == .any }?.items.map(\.account) ?? [])
|
|
||||||
|
|
||||||
var lines: [String] = []
|
|
||||||
lines.append("matched by strict `== true`: \(format(strict))")
|
|
||||||
lines.append("matched by `Any` (today): \(format(any))")
|
|
||||||
lines.append("visible to Yattee but NOT provenance-backed: \(format(any.subtracting(strict)))")
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func format(_ accounts: Set<String>) -> String {
|
|
||||||
accounts.isEmpty ? "none" : accounts.sorted().joined(separator: ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func describe(_ availability: CloudKitIdentity.AccountAvailability) -> String {
|
|
||||||
switch availability {
|
|
||||||
case .noAccount: "no account signed in"
|
|
||||||
case .available: "signed in"
|
|
||||||
case .indeterminate: "could not be determined"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Environment
|
|
||||||
|
|
||||||
private static var platformDescription: String {
|
|
||||||
let version = ProcessInfo.processInfo.operatingSystemVersionString
|
|
||||||
#if os(iOS)
|
|
||||||
return "iOS — \(UIDevice.current.model) — \(version)"
|
|
||||||
#elseif os(tvOS)
|
|
||||||
return "tvOS — \(UIDevice.current.model) — \(version)"
|
|
||||||
#elseif os(macOS)
|
|
||||||
return "macOS — \(version)"
|
|
||||||
#else
|
|
||||||
return version
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func iso(_ date: Date) -> String {
|
|
||||||
date.formatted(.iso8601)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -28,10 +28,6 @@ struct DeveloperSettingsView: View {
|
|||||||
debugSection
|
debugSection
|
||||||
testingSection
|
testingSection
|
||||||
dataSection
|
dataSection
|
||||||
#if DEBUG
|
|
||||||
experimentsSection
|
|
||||||
identityScopeSimulatorSection
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "settings.developer.title"))
|
.navigationTitle(String(localized: "settings.developer.title"))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -211,106 +207,6 @@ struct DeveloperSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@ViewBuilder
|
|
||||||
private var experimentsSection: some View {
|
|
||||||
SettingsFormSection("Experiments") {
|
|
||||||
#if os(tvOS)
|
|
||||||
NavigationLink {
|
|
||||||
KeychainProvenanceExperimentView()
|
|
||||||
} label: {
|
|
||||||
Label {
|
|
||||||
Text(verbatim: "Keychain Provenance")
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "key.viewfinder")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
SettingsNavigationRow("Keychain Provenance", systemImage: "key.viewfinder") {
|
|
||||||
KeychainProvenanceExperimentView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moves the credential identity scope by hand, the way signing in and out
|
|
||||||
/// of an Apple account does.
|
|
||||||
///
|
|
||||||
/// A simulator cannot sign into iCloud, so the scope is the only seam the
|
|
||||||
/// local→identity import can be exercised through: switching here posts the
|
|
||||||
/// same notification a real resolution posts, and the production observers
|
|
||||||
/// do the rest — reload the account namespace, sweep the signed-out one
|
|
||||||
/// into it, refresh statuses.
|
|
||||||
///
|
|
||||||
/// Account sync is suspended for the rest of the launch before the first
|
|
||||||
/// switch, and stays suspended. The namespaces are invented but the private
|
|
||||||
/// database is the device's real one, so an activation under a made-up scope
|
|
||||||
/// would bootstrap it out of the genuine zone and upload the results back
|
|
||||||
/// stamped with a name no Apple account answers to. The barrier is in the
|
|
||||||
/// coordinator, not in this caption.
|
|
||||||
@ViewBuilder
|
|
||||||
private var identityScopeSimulatorSection: some View {
|
|
||||||
if let scope = appEnvironment?.credentialIdentityScope {
|
|
||||||
SettingsFormSection("Identity Scope Simulator") {
|
|
||||||
HStack {
|
|
||||||
Label {
|
|
||||||
Text(verbatim: "Current scope")
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "person.badge.key")
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text(verbatim: scope.current ?? "unresolved")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
}
|
|
||||||
|
|
||||||
identityScopeButton("Switch to local", scope: CredentialIdentityScope.localScope, for: scope)
|
|
||||||
identityScopeButton("Switch to debug-identity-A", scope: "debug-identity-A", for: scope)
|
|
||||||
identityScopeButton("Switch to debug-identity-B", scope: "debug-identity-B", for: scope)
|
|
||||||
} footer: {
|
|
||||||
Text(
|
|
||||||
verbatim: """
|
|
||||||
Debug only. The scope stays where you put it until the app is relaunched, \
|
|
||||||
so it no longer describes the Apple account this device is signed in to. \
|
|
||||||
Switching suspends account iCloud sync — no fetching, no uploading — \
|
|
||||||
for the rest of the launch, so a made-up identity can never reach the \
|
|
||||||
real zone. Relaunch to sync again.
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func identityScopeButton(
|
|
||||||
_ title: String,
|
|
||||||
scope newScope: String,
|
|
||||||
for identityScope: CredentialIdentityScope
|
|
||||||
) -> some View {
|
|
||||||
HStack {
|
|
||||||
Button {
|
|
||||||
// Suspended first, and for good: the scope change is announced
|
|
||||||
// synchronously, so a coordinator told afterwards could already
|
|
||||||
// have activated against the real database under a name that
|
|
||||||
// belongs to nobody.
|
|
||||||
appEnvironment?.accountSyncCoordinator.enterDebugScopeSimulation()
|
|
||||||
|
|
||||||
// Announcing, so the observers that reload and import run —
|
|
||||||
// exactly what a resolution does.
|
|
||||||
identityScope.setFixedScope(newScope)
|
|
||||||
} label: {
|
|
||||||
Label {
|
|
||||||
Text(verbatim: title)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "arrow.triangle.swap")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(identityScope.current == newScope)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Data Actions
|
// MARK: - Data Actions
|
||||||
|
|
||||||
private func deduplicateData() {
|
private func deduplicateData() {
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
//
|
|
||||||
// KeychainProvenanceExperimentView.swift
|
|
||||||
// Yattee
|
|
||||||
//
|
|
||||||
// Debug-only driver for the Keychain provenance experiment (accounts revamp, D3).
|
|
||||||
//
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Runs the experiment described in ``KeychainProvenanceProbe``.
|
|
||||||
///
|
|
||||||
/// The protocol spans an Apple-account sign-out and sign-in, so the screen is
|
|
||||||
/// built around capturing readings rather than showing live state: each
|
|
||||||
/// "Capture report" writes a persisted snapshot that survives the switch, and
|
|
||||||
/// the whole set is copied out at the end.
|
|
||||||
struct KeychainProvenanceExperimentView: View {
|
|
||||||
@State private var label = ""
|
|
||||||
@State private var userRecordID: String?
|
|
||||||
@State private var accountAvailability: CloudKitIdentity.AccountAvailability = .indeterminate
|
|
||||||
@State private var resolvingUserRecordID = true
|
|
||||||
@State private var reports: [KeychainProvenanceReport] = KeychainProvenanceReportStore.load()
|
|
||||||
@State private var latestStatus: String?
|
|
||||||
@State private var showingDeleteConfirmation = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
SettingsFormContainer {
|
|
||||||
protocolSection
|
|
||||||
identitySection
|
|
||||||
runSection
|
|
||||||
reportsSection
|
|
||||||
}
|
|
||||||
.navigationTitle(Text(verbatim: "Keychain Provenance"))
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
.task {
|
|
||||||
accountAvailability = await CloudKitIdentity.accountAvailability()
|
|
||||||
userRecordID = await CloudKitIdentity.userRecordID()
|
|
||||||
resolvingUserRecordID = false
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
Text(verbatim: "Delete probe items?"),
|
|
||||||
isPresented: $showingDeleteConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
latestStatus = "Delete: " + KeychainProvenanceProbe.describe(KeychainProvenanceProbe.deleteAll())
|
|
||||||
} label: {
|
|
||||||
Text(verbatim: "Delete probe items")
|
|
||||||
}
|
|
||||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text(verbatim: "Deletes both probe items on this device. The synchronizable one also disappears from every device signed in to the same Apple account.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sections
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var protocolSection: some View {
|
|
||||||
SettingsFormSection(
|
|
||||||
"Protocol",
|
|
||||||
footer: "Two Apple accounts and a real device are required — iCloud Keychain does not behave faithfully in the Simulator. Do not press Write at steps 3 and 4: writing deletes the retained item the experiment is measuring."
|
|
||||||
) {
|
|
||||||
step(1, "Signed in as account A: Write both probes.")
|
|
||||||
step(2, "Wait ~1 minute for iCloud Keychain to sync, then Capture (label: \"A, before sign-out\").")
|
|
||||||
step(3, "Sign out of the Apple account in system Settings, choosing to KEEP Keychain data on device. Relaunch Yattee and Capture (label: \"signed out, kept\").")
|
|
||||||
step(4, "Sign in with account B. Relaunch Yattee and Capture (label: \"B, after switch\"). This is the decisive reading.")
|
|
||||||
step(5, "Write both probes again under B, then Capture (label: \"B, fresh write\").")
|
|
||||||
step(6, "Copy all reports and send them back.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var identitySection: some View {
|
|
||||||
SettingsFormSection(
|
|
||||||
"Identity",
|
|
||||||
footer: "The fingerprint is an opaque hash of ubiquityIdentityToken and must differ between accounts A and B — if it does not, the sign-out did not take effect. An unavailable token does not by itself mean signed out: it also goes missing when iCloud Drive is off, which is why the account status is shown separately."
|
|
||||||
) {
|
|
||||||
row("CloudKit entitlement", CloudKitAvailability.isAvailable ? "available" : "missing")
|
|
||||||
// A nil token is ambiguous — it also means iCloud Drive is off —
|
|
||||||
// so the account status is shown beside it rather than inferred.
|
|
||||||
row("Ubiquity token", CloudKitIdentity.ubiquityFingerprint.map { "fp=\($0)" } ?? "unavailable")
|
|
||||||
row("Apple account", accountStatusText)
|
|
||||||
row("User record", resolvingUserRecordID ? "resolving…" : (userRecordID ?? "unresolved"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var runSection: some View {
|
|
||||||
SettingsFormSection(
|
|
||||||
"Run",
|
|
||||||
footer: "The label is stored inside the probe payload, so a report taken after an account switch still shows which account wrote the item it found."
|
|
||||||
) {
|
|
||||||
TextField(text: $label) {
|
|
||||||
Text(verbatim: "Label (e.g. \"A, before sign-out\")")
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
actionRow("Write both probes", systemImage: "square.and.pencil") {
|
|
||||||
writeProbes()
|
|
||||||
}
|
|
||||||
|
|
||||||
actionRow("Capture report", systemImage: "camera.viewfinder") {
|
|
||||||
capture()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let latestStatus {
|
|
||||||
Text(verbatim: latestStatus)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var reportsSection: some View {
|
|
||||||
SettingsFormSection("Reports (\(reports.count))") {
|
|
||||||
if reports.isEmpty {
|
|
||||||
Text(verbatim: "No reports captured yet.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(reports) { report in
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(verbatim: report.label.isEmpty ? "<unlabelled>" : report.label)
|
|
||||||
.font(.headline)
|
|
||||||
Text(verbatim: report.capturedAt.formatted(date: .abbreviated, time: .standard))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(verbatim: report.text)
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.selectableText()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
actionRow("Copy all reports", systemImage: "doc.on.doc") {
|
|
||||||
copyToClipboard(allReportsText)
|
|
||||||
latestStatus = "Copied \(reports.count) report(s) to the clipboard."
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
actionRow("Clear reports", systemImage: "trash", role: .destructive) {
|
|
||||||
KeychainProvenanceReportStore.clear()
|
|
||||||
reports = []
|
|
||||||
latestStatus = "Cleared captured reports (probe items untouched)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actionRow("Delete probe items", systemImage: "key.slash", role: .destructive) {
|
|
||||||
showingDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
|
|
||||||
private func writeProbes() {
|
|
||||||
let payload = payloadText()
|
|
||||||
let synchronizable = KeychainProvenanceProbe.write(
|
|
||||||
account: KeychainProvenanceProbe.synchronizableAccount,
|
|
||||||
synchronizable: true,
|
|
||||||
payload: payload
|
|
||||||
)
|
|
||||||
let local = KeychainProvenanceProbe.write(
|
|
||||||
account: KeychainProvenanceProbe.localAccount,
|
|
||||||
synchronizable: false,
|
|
||||||
payload: payload
|
|
||||||
)
|
|
||||||
|
|
||||||
latestStatus = """
|
|
||||||
Wrote probes — synchronizable: \(KeychainProvenanceProbe.describe(synchronizable)), \
|
|
||||||
local: \(KeychainProvenanceProbe.describe(local))
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
private func capture() {
|
|
||||||
let text = KeychainProvenanceReportBuilder.build(
|
|
||||||
label: label,
|
|
||||||
userRecordID: userRecordID,
|
|
||||||
accountAvailability: accountAvailability
|
|
||||||
)
|
|
||||||
reports = KeychainProvenanceReportStore.append(
|
|
||||||
KeychainProvenanceReport(label: label, text: text)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Also logged so the report survives even if the app's storage is
|
|
||||||
// reset, and so it can be exported from the log viewer on tvOS.
|
|
||||||
LoggingService.shared.info(
|
|
||||||
"Keychain provenance report captured",
|
|
||||||
category: .keychain,
|
|
||||||
details: text
|
|
||||||
)
|
|
||||||
|
|
||||||
latestStatus = "Captured report \(reports.count)."
|
|
||||||
}
|
|
||||||
|
|
||||||
private var accountStatusText: String {
|
|
||||||
if resolvingUserRecordID { return "checking…" }
|
|
||||||
switch accountAvailability {
|
|
||||||
case .noAccount: return "no account signed in"
|
|
||||||
case .available: return "signed in"
|
|
||||||
case .indeterminate: return "could not be determined"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func payloadText() -> String {
|
|
||||||
let fingerprint = CloudKitIdentity.ubiquityFingerprint ?? "none"
|
|
||||||
let written = Date().formatted(.iso8601)
|
|
||||||
return "fp=\(fingerprint)|written=\(written)|label=\(label.isEmpty ? "<unlabelled>" : label)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var allReportsText: String {
|
|
||||||
reports
|
|
||||||
.map { "\($0.text)\n" }
|
|
||||||
.joined(separator: "\n\n========================================\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
private func copyToClipboard(_ text: String) {
|
|
||||||
#if os(macOS)
|
|
||||||
NSPasteboard.general.clearContents()
|
|
||||||
NSPasteboard.general.setString(text, forType: .string)
|
|
||||||
#else
|
|
||||||
UIPasteboard.general.string = text
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Row builders
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func step(_ number: Int, _ text: String) -> some View {
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
||||||
Text(verbatim: "\(number).")
|
|
||||||
.font(.footnote.monospacedDigit())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(verbatim: text)
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func row(_ title: String, _ value: String) -> some View {
|
|
||||||
HStack {
|
|
||||||
Text(verbatim: title)
|
|
||||||
Spacer()
|
|
||||||
Text(verbatim: value)
|
|
||||||
.font(.system(.footnote, design: .monospaced))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.selectableText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func actionRow(
|
|
||||||
_ title: String,
|
|
||||||
systemImage: String,
|
|
||||||
role: ButtonRole? = nil,
|
|
||||||
action: @escaping () -> Void
|
|
||||||
) -> some View {
|
|
||||||
HStack {
|
|
||||||
Button(role: role, action: action) {
|
|
||||||
Label {
|
|
||||||
Text(verbatim: title)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: systemImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
/// tvOS has no text selection — the report is read back from the log
|
|
||||||
/// viewer there instead.
|
|
||||||
@ViewBuilder
|
|
||||||
func selectableText() -> some View {
|
|
||||||
#if os(tvOS)
|
|
||||||
self
|
|
||||||
#else
|
|
||||||
textSelection(.enabled)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationStack {
|
|
||||||
KeychainProvenanceExperimentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1127,43 +1127,6 @@ struct AccountSyncTests {
|
|||||||
#expect(didDiscardCachedRecords)
|
#expect(didDiscardCachedRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Simulated identities
|
|
||||||
|
|
||||||
@Test("A simulated identity scope cannot reach the real zone")
|
|
||||||
func debugScopeSimulationSuspendsSync() async throws {
|
|
||||||
// Everything a send is normally gated on is satisfied: the category is
|
|
||||||
// on, the namespace has bootstrapped, the manager is loaded, and the
|
|
||||||
// outbox is owed a record.
|
|
||||||
let harness = Self.makeHarness()
|
|
||||||
let account = harness.accounts.addAccount(instanceID: UUID())
|
|
||||||
let recordName = "account-\(account.id.uuidString)"
|
|
||||||
#expect(harness.accounts.pendingUploads.contains(account.id))
|
|
||||||
|
|
||||||
harness.coordinator.enterDebugScopeSimulation()
|
|
||||||
harness.recorder.clear()
|
|
||||||
|
|
||||||
await harness.coordinator.activate()
|
|
||||||
harness.coordinator.enqueueDirtyRecords()
|
|
||||||
harness.coordinator.uploadEverything()
|
|
||||||
|
|
||||||
#expect(harness.recorder.changes.isEmpty)
|
|
||||||
#expect(harness.coordinator.recordToSave(named: recordName) == nil)
|
|
||||||
|
|
||||||
// Records the engine delivers are refused rather than filed under an
|
|
||||||
// invented name — and the real identity is told to bootstrap again, so
|
|
||||||
// what the change token skipped is not lost.
|
|
||||||
harness.coordinator.stageFetched(
|
|
||||||
records: [harness.mapper.toCKRecord(accountDeletionID: UUID())],
|
|
||||||
deletions: []
|
|
||||||
)
|
|
||||||
#expect(harness.store.stagedChanges(in: harness.scope).isEmpty)
|
|
||||||
#expect(harness.store.hasPendingIdentityRecovery)
|
|
||||||
|
|
||||||
// The debt itself is untouched: it is owed to whatever identity is real
|
|
||||||
// after the relaunch that ends the simulation.
|
|
||||||
#expect(harness.accounts.pendingUploads.contains(account.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Importing the signed-out namespace
|
// MARK: - Importing the signed-out namespace
|
||||||
|
|
||||||
/// What the accounts created while signed out owe the server once an Apple
|
/// What the accounts created while signed out owe the server once an Apple
|
||||||
|
|||||||
Reference in New Issue
Block a user