Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ let package = Package(
dependencies: [
"AskKeySystem",
"AskKeyBroker",
"AskKeyDevices",
.product(name: "GRDB", package: "GRDB.swift"),
],
path: "Sources/AskKeyVault",
Expand All @@ -73,6 +74,7 @@ let package = Package(
"AskKeyVault",
"AskKeySystem",
"AskKeyIntegrations",
"AskKeyDevices",
],
path: "Sources/AskKeyAppKit",
resources: [
Expand All @@ -97,7 +99,8 @@ let package = Package(
),
.target(
name: "AskKeyTestSupport",
dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyBroker"],
dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyBroker", "AskKeyDevices",
"AskKeyDevicesTestSupport"],
path: "Tests/AskKeyTestSupport",
swiftSettings: [.swiftLanguageMode(.v5)]
),
Expand Down Expand Up @@ -138,14 +141,16 @@ let package = Package(
),
.testTarget(
name: "AskKeyVaultTests",
dependencies: ["AskKeyBroker", "AskKeyVault", "AskKeyHelper", "AskKeyUnitTestSupport"],
dependencies: ["AskKeyBroker", "AskKeyVault", "AskKeyHelper", "AskKeyUnitTestSupport", "AskKeyDevices",
"AskKeyDevicesTestSupport"],
path: "Tests/AskKeyVaultTests",
resources: [.copy("Fixtures")],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "AskKeyAppTests",
dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyIntegrations", "AskKeyTestSupport"],
dependencies: ["AskKeyAppKit", "AskKeyVault", "AskKeySystem", "AskKeyIntegrations", "AskKeyTestSupport",
"AskKeyDevices", "AskKeyDevicesTestSupport"],
path: "Tests/AskKeyAppTests",
swiftSettings: [.swiftLanguageMode(.v5)]
),
Expand Down
2 changes: 2 additions & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+BrokerLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ extension AppDelegate {
setupApprovalQueue()
AppRuntimeState.configuration.prepareServices?()
startBroker()
setupDeviceServices()
didStart()
return
}
Expand All @@ -61,6 +62,7 @@ extension AppDelegate {
setupWindowBehavior()
setupStatusItemMenu()
setupSoftwareUpdates()
setupDeviceServices()
NotificationCenter.default.addObserver(
forName: .hotkeyShortcutChanged, object: nil, queue: .main
) { [weak self] note in
Expand Down
81 changes: 81 additions & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+Devices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import AppKit
import AskKeyVault
import Observation

extension AppDelegate {
/// Creates and starts the device services (ADR 0013). Without a factory
/// from the executable, the CloudKit backend exists only when
/// `CloudKitGate` allows it; otherwise Devices reports "not configured"
/// and no CloudKit object is created.
func setupDeviceServices() {
guard !ManagementAuthenticationSubprocess.isActive, DeviceServicesHost.coordinator == nil else { return }
let backend: (any DeviceServicesBackend)?
if let makeDeviceServices = AppRuntimeState.configuration.makeDeviceServices {
backend = makeDeviceServices()
} else {
backend = CloudKitGate.currentProcessDecision().containerIdentifier
.map { CloudKitDeviceServicesBackend(containerIdentifier: $0) }
}
let model = vault
let coordinator = DeviceCoordinator(environment: DeviceCoordinatorEnvironment(
vault: .shared,
backend: backend,
inboxState: VaultDeviceInboxStateStore(vault: .shared),
authenticate: { [weak model] reason in
await model?.deviceOwnerAuthentication(reason: reason) ?? .cancelled
},
isManagementSessionActive: { [weak model] in
guard let model else { return false }
return model.hasManagementSession && !model.isLocked
}
))
DeviceServicesHost.install(coordinator)
guard let backend else { return }
Vault.shared.setDeviceServicesStopHandler { [weak coordinator] in
// The Vault already refuses every later write of these services.
if Thread.isMainThread {
MainActor.assumeIsolated { coordinator?.stopForLocalErase() }
} else {
DispatchQueue.main.async { coordinator?.stopForLocalErase() }
}
}
observeManagementSessionForDevices()
if backend.registersForRemoteNotifications {
NSApp.registerForRemoteNotifications()
}
coordinator.start()
}

func stopDeviceServices() {
DeviceServicesHost.coordinator?.stop()
}

/// Locking management, its expiry or closing its window cancels an open
/// pairing session (ADR 0013 → Pairing, step 6).
private func observeManagementSessionForDevices() {
withObservationTracking {
_ = vault.hasManagementSession
_ = vault.isLocked
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Observe again first: cancelling sends a message and suspends.
self.observeManagementSessionForDevices()
if !self.vault.hasManagementSession || self.vault.isLocked {
await DeviceServicesHost.coordinator?.managementSessionEnded()
}
}
}
}

/// A push is only a hint to fetch this Mac's inbox; nothing in it is
/// trusted or shown.
func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) {
let hint = Dictionary(uniqueKeysWithValues: userInfo.map { (AnyHashable($0.key), $0.value) })
DeviceServicesHost.coordinator?.handleRemoteNotification(hint)
}

func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("AskKey: remote notifications are unavailable; device messages are fetched by polling")
}
}
1 change: 1 addition & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+Termination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension AppDelegate {
func applicationWillTerminate(_ notification: Notification) {
guard !ManagementAuthenticationSubprocess.isActive else { return }
AppRuntimeState.configuration.willStop?()
stopDeviceServices()
brokerServer?.stop()
recycleBinCleanupTimer?.invalidate()
timedAllowanceSessionObserver = nil
Expand Down
10 changes: 9 additions & 1 deletion Sources/AskKeyAppKit/AppRuntimeConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,34 @@ package struct AppRuntimeConfiguration {
/// Only the production executable passes one; the App still creates the
/// updater only when `SoftwareUpdateAvailability` allows it.
package let makeSoftwareUpdater: SoftwareUpdaterFactory?
/// The device services' backend (ADR 0013). The production executable
/// passes none: the App then creates the CloudKit backend only when
/// `CloudKitGate` allows it. The E2E App passes a fake.
package let makeDeviceServices: DeviceServicesFactory?

package init(
makeViewModel: (() -> VaultViewModel)? = nil,
prepareServices: (() -> Void)? = nil,
didStart: (() -> Void)? = nil,
willStop: (() -> Void)? = nil,
startClient: (() -> Bool)? = nil,
makeSoftwareUpdater: SoftwareUpdaterFactory? = nil
makeSoftwareUpdater: SoftwareUpdaterFactory? = nil,
makeDeviceServices: DeviceServicesFactory? = nil
) {
self.makeViewModel = makeViewModel
self.prepareServices = prepareServices
self.didStart = didStart
self.willStop = willStop
self.startClient = startClient
self.makeSoftwareUpdater = makeSoftwareUpdater
self.makeDeviceServices = makeDeviceServices
}

package static var production: Self { Self() }
}

package typealias DeviceServicesFactory = @MainActor () -> any DeviceServicesBackend

extension VaultViewModel {
package static func configured(
languageMode: String,
Expand Down
49 changes: 49 additions & 0 deletions Sources/AskKeyAppKit/Devices/CloudKitGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation
import Security

/// Decides whether this process may create the CloudKit transport (ADR 0013
/// → CloudKit layout). Creating a `CKContainer` terminates an App that lacks
/// the entitlement, so Debug, E2E and CI builds never create one: only a
/// build whose Info.plist declares the container (`CloudKitContainerIdentifier`,
/// written by the CloudKit option of `scripts/build-app.sh`) and whose signed
/// entitlements contain that container and the CloudKit service qualifies.
/// Every other build reports Devices as not configured and creates no
/// CloudKit object.
package enum CloudKitGate {
package static let infoPlistKey = "CloudKitContainerIdentifier"
package static let containerEntitlement = "com.apple.developer.icloud-container-identifiers"
package static let servicesEntitlement = "com.apple.developer.icloud-services"

package enum Decision: Equatable, Sendable {
case configured(containerIdentifier: String)
case notConfigured

package var containerIdentifier: String? {
if case .configured(let identifier) = self { return identifier }
return nil
}
}

/// The decision for an Info.plist and a reader of this process's signed
/// entitlements. The reader runs only when the Info.plist declares a
/// container.
package static func decide(infoDictionary: [String: Any], entitlement: (String) -> Any?) -> Decision {
guard let declared = infoDictionary[infoPlistKey] as? String, !declared.isEmpty else { return .notConfigured }
guard let containers = entitlement(containerEntitlement) as? [String], containers.contains(declared),
let services = entitlement(servicesEntitlement) as? [String], services.contains("CloudKit") else {
return .notConfigured
}
return .configured(containerIdentifier: declared)
}

/// The decision for this process: its main bundle's Info.plist and its
/// own code signature's entitlements.
package static func currentProcessDecision() -> Decision {
decide(infoDictionary: Bundle.main.infoDictionary ?? [:], entitlement: signedEntitlement)
}

private static func signedEntitlement(_ key: String) -> Any? {
guard let task = SecTaskCreateFromSelf(nil) else { return nil }
return SecTaskCopyValueForEntitlement(task, key as CFString, nil)
}
}
134 changes: 134 additions & 0 deletions Sources/AskKeyAppKit/Devices/DeviceCoordinator+Devices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import AskKeyDevices
import AskKeyVault
import Foundation

/// Delivery checks, renaming, removal and the identity reset.
extension DeviceCoordinator {
/// Sends a delivery check, after pairing and on request, then polls every
/// two seconds for up to a minute; a later reply within the check's
/// 10-minute lifetime still counts. A device stays "not reached yet"
/// until the first reply.
package func sendDeliveryCheck(to id: DeviceID) async {
guard let services, availability == .ready else { return }
let lifecycle = self.lifecycle
lastIssue = nil
do {
try await services.messenger.sendDeliveryCheck(to: id)
guard lifecycle == self.lifecycle else { return }
unreachableDevices.remove(id)
} catch DeviceTransportError.recipientUnreachable {
guard lifecycle == self.lifecycle else { return }
unreachableDevices.insert(id)
lastIssue = .deliveryCheckFailed
} catch DeviceIdentityError.unusable {
guard lifecycle == self.lifecycle else { return }
identityBecameUnusable()
} catch DeviceMessengerError.unknownDevice {
guard lifecycle == self.lifecycle else { return }
lastIssue = .deviceNotFound
} catch {
guard lifecycle == self.lifecycle else { return }
lastIssue = .deliveryCheckFailed
}
await publish()
startDeliveryPolling()
}

func startDeliveryPolling() {
deliveryPollDeadline = now.addingTimeInterval(Self.deliveryPollDuration)
guard environment.automaticPolling, deliveryPollTask == nil else { return }
let sleep = environment.sleep
deliveryPollTask = Task { [weak self] in
while !Task.isCancelled {
try? await sleep(Self.deliveryPollInterval)
guard let self, !Task.isCancelled, let deadline = self.deliveryPollDeadline, self.now < deadline else { break }
await self.refresh()
}
self?.deliveryPollTask = nil
}
}

/// Renames a device, in the management session. A rename prepared
/// against a row whose entry was removed or paired again since is dropped.
@discardableResult
package func renameDevice(_ row: PairedDeviceRow, to displayName: String) async -> Bool {
guard let services else { return false }
guard managementSessionIsOpen() else {
lastIssue = .managementRequired
return false
}
lastIssue = nil
let outcome: PairedDeviceChangeOutcome
do {
outcome = try await services.registry.apply(.rename(row.id, pairingID: row.pairingID, displayName: displayName))
} catch {
lastIssue = .actionFailed
await publish()
return false
}
if outcome == .dropped { lastIssue = .deviceNotFound }
await publish()
return outcome == .applied
}

/// Removes a device at once, in the management session: from then on its
/// messages are refused, any pairing session or delivery check with it
/// ends, and it gets an unpair notice. Removal only reduces trust, so it
/// needs no fresh authentication.
@discardableResult
package func removeDevice(_ id: DeviceID) async -> Bool {
guard let services else { return false }
guard managementSessionIsOpen() else {
lastIssue = .managementRequired
return false
}
let lifecycle = self.lifecycle
lastIssue = nil
var removed = false
do {
let noticeSent = try await services.messenger.removeDevice(id)
removed = true
if !noticeSent, lifecycle == self.lifecycle { lastIssue = .unpairNoticeNotSent }
} catch DeviceMessengerError.unknownDevice {
if lifecycle == self.lifecycle { lastIssue = .deviceNotFound }
} catch DeviceIdentityError.unusable {
// The device is off the list; only the notice could not be signed.
removed = true
if lifecycle == self.lifecycle { identityBecameUnusable() }
} catch {
if lifecycle == self.lifecycle { lastIssue = .actionFailed }
}
guard lifecycle == self.lifecycle else { return removed }
unreachableDevices.remove(id)
await publish()
return removed
}

/// The one action an unusable identity or a refused list offers: deletes
/// this Mac's inbox when CloudKit is reachable, then the keys, the list
/// and the inbox state, so the person can pair again. Needs the
/// management session.
@discardableResult
package func resetIdentity() async -> Bool {
guard availability == .identityUnusable || availability == .listRefused else { return false }
guard managementSessionIsOpen() else {
lastIssue = .managementRequired
return false
}
let inbox = services?.inbox
stopServices()
let lifecycle = self.lifecycle
if let inbox { try? await inbox.removeInbox() }
guard lifecycle == self.lifecycle else { return false }
do {
try vault.resetDeviceIdentity()
} catch {
storageUnavailable(error)
return false
}
lastIssue = nil
storageFailureMessage = nil
availability = .ready
return true
}
}
Loading
Loading