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
23 changes: 23 additions & 0 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,29 @@ final class AppEnvironment: ObservableObject {
// who opts in *after* a crash still has a report to send.
let crashReportService = CrashReportService()
Self.installCrashHandler(service: crashReportService)
// Publish this machine's device id to the shared Keychain group at
// launch (GitHub issue #104), not when Settings ▸ Applications is first
// opened. The document-sync agent needs the id to address its
// per-machine settings document, and most users never visit that pane —
// leaving the agent stranded on local `UserDefaults` forever.
//
// **Detached, and that is not a nicety.** `DeviceIdentity.current()`
// reads and writes the Keychain, which is synchronous IPC to `securityd`
// against a shared access group. Calling it inline here hung the process
// at launch outright — the App test host never finished starting, and
// `xcodebuild test` failed with "The test runner hung before
// establishing connection" on every run. An unsigned or
// wrongly-entitled build has no claim on that access group, and the
// failure mode is a stall rather than a clean `errSecMissingEntitlement`.
//
// Even signed and entitled, a locked or first-unlock Keychain can make
// this slow. Nothing on the launch path should wait on it: the agent
// reads the published id on its own schedule, so being a few hundred
// milliseconds late costs nothing, and the Applications pane calls
// `current()` directly when it genuinely needs the value synchronously.
Task.detached(priority: .utility) {
DeviceIdentity.current()
}
return AppEnvironment(
messages: messages,
lists: lists,
Expand Down
54 changes: 48 additions & 6 deletions App/Composition/DeviceIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,63 @@
// It is an opaque UUID minted once per machine — no hardware identifier, so it
// carries nothing personally identifying and resets cleanly if the user wipes
// preferences.
//
// GitHub issue #104 adds a second home for the same value: the shared Keychain
// group, so the bundled document-sync agent — a separate process with its own
// defaults domain — can address the *same* per-machine settings document this
// app does. `UserDefaults` remains the primary source of truth so machines
// already registered under an id keep it.

import Foundation
import InterlinedKit

enum DeviceIdentity {

private static let defaultsKey = "com.interlinedlist.deviceId"

/// The stable id for this machine, minting and persisting one on first use.
static func current(defaults: UserDefaults = .standard) -> String {
/// The shared-Keychain channel the sync agent reads. Service and group must
/// match the agent's `SyncConfiguration.deviceIDService` /
/// `sharedAccessGroup` — two independent codebases agreeing on one contract.
static let sharedStore: any DeviceIDStoring = KeychainDeviceIDStore(
service: "com.interlinedlist.macos.device-id",
accessGroup: "BJA9558E4B.com.interlinedlist.shared"
)

/// The stable id for this machine, minting and persisting one on first use,
/// and publishing it where the sync agent can find it.
///
/// Resolution order matters:
///
/// 1. **`UserDefaults`** — a machine already registered with the server
/// keeps the id its registry row is keyed on. Preferring anything else
/// would orphan that row and its per-machine settings.
/// 2. **the shared Keychain** — the app's preferences were wiped or
/// reinstalled while the agent stayed put. Adopting the published id
/// reunites the app with the machine's existing registry row instead of
/// minting a duplicate.
/// 3. **mint** — genuinely new machine.
@discardableResult
static func current(
defaults: UserDefaults = .standard,
sharedStore: any DeviceIDStoring = DeviceIdentity.sharedStore
) -> String {
let resolved: String
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
return existing
resolved = existing
} else if let published = sharedStore.read(), !published.isEmpty {
resolved = published
defaults.set(published, forKey: defaultsKey)
} else {
resolved = UUID().uuidString
defaults.set(resolved, forKey: defaultsKey)
}

// Publish only on a mismatch. The Keychain write is cheap but not free,
// and `current()` is called on every Applications-pane load.
if sharedStore.read() != resolved {
sharedStore.write(resolved)
}
let minted = UUID().uuidString
defaults.set(minted, forKey: defaultsKey)
return minted
return resolved
}

/// A human-friendly default name for this machine, used when registering.
Expand Down
28 changes: 28 additions & 0 deletions App/Features/Settings/DevicesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,34 @@ struct DeviceSettingsInspector: View {
Section("Settings saved for this machine") {
SettingsDocumentSummary(document: document)
}
// What the machine is actually doing, not just that it has a
// document (GitHub issue #104). Only the document-sync agent
// currently writes anything a human can be told about, so
// this is the one interpreted section on an otherwise
// deliberately opaque sheet.
if let sync = DocumentSyncStatus(
bag: document.bag,
deviceID: inspection.device.id
) {
Section("Document Sync") {
LabeledContent("Sync folder") {
// The path itself is not shown — see the Keys
// section below on why values stay hidden.
Text(sync.isConfigured ? "Configured on this machine" : "Not configured here")
.foregroundStyle(sync.isConfigured ? .primary : .secondary)
}
LabeledContent("Syncing") {
Text(sync.isEnabled ? "On" : "Paused")
}
LabeledContent("Last synced") {
if let lastSyncAt = sync.lastSyncAt {
Text(lastSyncAt.formatted(.relative(presentation: .named)))
} else {
Text("Never").foregroundStyle(.secondary)
}
}
}
}
Section("Keys") {
if document.bag.isEmpty {
Text("None").foregroundStyle(.secondary)
Expand Down
105 changes: 105 additions & 0 deletions AppTests/DeviceIdentityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// DeviceIdentityTests
//
// This machine's app-settings device id, and the shared-Keychain channel that
// lets the bundled document-sync agent address the *same* per-machine settings
// document the app does (GitHub issue #104).
//
// Two ids for one Mac would mean two rows in the device registry and a
// configuration split across two documents, so the resolution order is the
// behaviour under test — not an implementation detail.

import XCTest
import InterlinedKit
@testable import InterlinedList

final class DeviceIdentityTests: XCTestCase {

private func freshDefaults() -> UserDefaults {
UserDefaults(suiteName: "iltest-device-\(UUID().uuidString)")!
}

// MARK: - Happy path

func test_givenAMachineAlreadyRegistered_whenResolving_thenItKeepsTheIdItsRegistryRowUses() {
let defaults = freshDefaults()
defaults.set("existing-id", forKey: "com.interlinedlist.deviceId")
let shared = InMemoryDeviceIDStore()

let resolved = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

// Preferring anything else would orphan the registry row and the
// per-machine settings filed under it.
XCTAssertEqual(resolved, "existing-id")
XCTAssertEqual(shared.read(), "existing-id", "…and the agent is told about it")
}

func test_givenANewMachine_whenResolving_thenItMintsOnceAndPublishesIt() {
let defaults = freshDefaults()
let shared = InMemoryDeviceIDStore()

let first = DeviceIdentity.current(defaults: defaults, sharedStore: shared)
let second = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

XCTAssertFalse(first.isEmpty)
XCTAssertEqual(first, second, "Minting twice would register the Mac twice")
XCTAssertEqual(shared.read(), first)
}

// MARK: - Boundary: the app's preferences were wiped

func test_givenTheAppsPreferencesWereWipedButTheAgentRemains_whenResolving_thenThePublishedIdIsAdopted() {
let defaults = freshDefaults()
let shared = InMemoryDeviceIDStore(initial: "published-id")

let resolved = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

// Reinstalling the app must reunite it with this machine's existing
// registry row rather than minting a duplicate alongside it.
XCTAssertEqual(resolved, "published-id")
XCTAssertEqual(defaults.string(forKey: "com.interlinedlist.deviceId"), "published-id")
}

// MARK: - Invalid input

func test_givenAnEmptyStoredId_whenResolving_thenItIsTreatedAsAbsent() {
let defaults = freshDefaults()
defaults.set("", forKey: "com.interlinedlist.deviceId")
let shared = InMemoryDeviceIDStore()

let resolved = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

XCTAssertFalse(resolved.isEmpty)
}

func test_givenAnEmptyPublishedId_whenResolving_thenItIsTreatedAsAbsent() {
let defaults = freshDefaults()
let shared = InMemoryDeviceIDStore(initial: "")

let resolved = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

XCTAssertFalse(resolved.isEmpty)
XCTAssertEqual(shared.read(), resolved)
}

// MARK: - Upstream failure: the Keychain is unavailable

func test_givenTheSharedStoreCannotBeWritten_whenResolving_thenTheAppStillGetsAnId() {
// An unsigned build, or one without the shared access-group entitlement,
// cannot use the Keychain at all. The app degrades to local-only — the
// agent falls back to its own settings — rather than failing at launch.
let defaults = freshDefaults()
let shared = UnwritableDeviceIDStore()

let resolved = DeviceIdentity.current(defaults: defaults, sharedStore: shared)

XCTAssertFalse(resolved.isEmpty)
XCTAssertEqual(defaults.string(forKey: "com.interlinedlist.deviceId"), resolved)
}
}

/// A shared store that silently drops every write, standing in for a process
/// that is not entitled to the shared Keychain group.
private final class UnwritableDeviceIDStore: DeviceIDStoring, @unchecked Sendable {
func read() -> String? { nil }
func write(_ id: String) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation
import InterlinedKit

/// What the Document Sync Agent is doing on one machine, read out of that
/// machine's per-machine app-settings document (GitHub issue #104).
///
/// The agent stores its configuration there rather than in `UserDefaults`, which
/// is what makes this readable at all: before the move, Settings ▸ Applications
/// could list a machine and say nothing whatsoever about what it was set up to
/// do.
///
/// **This type reads keys written by a different codebase.** The agent
/// (`SyncAgent/`) is a clean-room package with no dependency on `InterlinedKit`
/// or `InterlinedDomain`, so the key names below are a wire contract with
/// `DocumentSyncSettingsKeys` in
/// `SyncAgent/Sources/InterlinedListSyncCore/Storage/SyncAgentConfiguration.swift`.
/// Renaming one side without the other does not fail to build — it silently
/// stops the pane reporting anything.
public struct DocumentSyncStatus: Sendable, Equatable {

/// The wire keys, mirrored from the agent.
private enum Key {
static let prefix = "documentSync."
static let enabled = prefix + "enabled"
static let folderBookmark = prefix + "folderBookmark"
static let folderMachineID = prefix + "folderMachineId"
static let lastSyncAt = prefix + "lastSyncAt"
}

/// Whether a sync folder is set up **on the machine that owns this
/// document**.
///
/// False when the stored bookmark was created on another Mac. That can
/// happen legitimately — copying a machine's settings to shared, or a new
/// machine seeding from the main workstation, both move the document across
/// machines — and a bookmark is meaningless anywhere but where it was made.
/// Reporting it as configured would promise the user something that is not
/// true on that computer.
public let isConfigured: Bool

/// Whether syncing is switched on. A machine can be configured but paused.
public let isEnabled: Bool

/// When that machine last completed a sync cycle, if it has reported one.
/// Published on a throttle by the agent, so it lags by up to an hour.
public let lastSyncAt: Date?

public init(isConfigured: Bool, isEnabled: Bool, lastSyncAt: Date? = nil) {
self.isConfigured = isConfigured
self.isEnabled = isEnabled
self.lastSyncAt = lastSyncAt
}

/// Projects the agent's status out of a settings document.
///
/// Returns nil when the document holds none of the agent's keys — that
/// machine has never run the agent, which is a different statement from
/// "has it installed but switched off", and the pane says so differently.
///
/// - Parameter deviceID: the machine the document belongs to, used to tell
/// its own sync folder from one that travelled in from elsewhere.
public init?(bag: AppSettingsBag, deviceID: String) {
guard bag.keys.contains(where: { $0.hasPrefix(Key.prefix) }) else { return nil }
let bookmark = bag[string: Key.folderBookmark]
let owner = bag[string: Key.folderMachineID]
self.init(
// An unstamped bookmark counts as foreign: nothing has ever written
// one, so the only way to see one is a document this machine did not
// produce, and "cannot prove it is ours" must read as "not ours".
isConfigured: !(bookmark ?? "").isEmpty && owner == deviceID,
isEnabled: bag[bool: Key.enabled] ?? false,
lastSyncAt: bag[string: Key.lastSyncAt].flatMap(JSONCoders.parseDate)
)
}
}
Loading